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 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 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/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..866075fb99 100644 --- a/src/compas_viewer/commands.py +++ b/src/compas_viewer/commands.py @@ -21,8 +21,6 @@ from compas.datastructures import Datastructure 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 @@ -116,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) @@ -490,19 +497,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..b7e4b93780 100644 --- a/src/compas_viewer/components/__init__.py +++ b/src/compas_viewer/components/__init__.py @@ -1,22 +1,22 @@ from .button import Button -from .combobox import ComboBox -from .combobox import ViewModeAction -from .camerasetting import CameraSettingsDialog -from .objectsetting import ObjectSettingDialog +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", - "ObjectSettingDialog", + "CameraSetting", "Renderer", "Slider", "TextEdit", "Treeform", "Sceneform", - "ViewModeAction", + "ObjectSetting", + "Tabform", + "Component", ] diff --git a/src/compas_viewer/components/booleantoggle.py b/src/compas_viewer/components/booleantoggle.py new file mode 100644 index 0000000000..48e668290a --- /dev/null +++ b/src/compas_viewer/components/booleantoggle.py @@ -0,0 +1,92 @@ +from typing import Callable +from typing import Union + +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): + """ + 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 action 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. + action : 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. + 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 + 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, + action: Callable[[Component, bool], None] = None, + ): + super().__init__(obj, attr, action=action) + + 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 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 action.""" + # Convert Qt checkbox state to boolean + is_checked = state == 2 # Qt.Checked = 2 + self.set_attr(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 new file mode 100644 index 0000000000..1b5f2de1b9 --- /dev/null +++ b/src/compas_viewer/components/boundcomponent.py @@ -0,0 +1,202 @@ +from typing import Any +from typing import Callable +from typing import Union + +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 action 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. + 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 + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute being bound. + attr : str + 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 + ------- + >>> class MyObject: + ... def __init__(self): + ... self.value = 10.0 + >>> 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 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], 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): + """ + Get the current value of the bound attribute. + + Returns + ------- + 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: + 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 self.obj is None or self.attr is None: + return + 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 action function if one was provided. + + Parameters + ---------- + value : float + The new value to set. + """ + 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: + 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/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/camerasetting.py b/src/compas_viewer/components/camerasetting.py index 09cfb23102..e5e3e73e49 100644 --- a/src/compas_viewer/components/camerasetting.py +++ b/src/compas_viewer/components/camerasetting.py @@ -1,67 +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 + + # 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 = QVBoxLayout(self) - self.setting_layout = SettingLayout(viewer=self.viewer, items=items, type="camera_setting") - self.setting_layout.generate_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.layout.addLayout(self.setting_layout.layout) + # 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", 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) - self.update_button = QPushButton("Update Camera", self) - self.update_button.clicked.connect(self.update) - self.layout.addWidget(self.update_button) + # 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/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..34d77a5e1e --- /dev/null +++ b/src/compas_viewer/components/colorpicker.py @@ -0,0 +1,141 @@ +from typing import Callable +from typing import Union + +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QColorDialog +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 .boundcomponent import BoundComponent +from .component import Component + + +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 action 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. + action : 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. + 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 + 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, + action: Callable[[Component, Color], None] = None, + ): + super().__init__(obj, attr, action=action) + + 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/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/component.py b/src/compas_viewer/components/component.py new file mode 100644 index 0000000000..9f047bcbb3 --- /dev/null +++ b/src/compas_viewer/components/component.py @@ -0,0 +1,31 @@ +from PySide6.QtWidgets import QWidget + +from compas_viewer.base import Base + + +class Component(Base): + """A base class for all UI components in the viewer. + + Attributes + ---------- + widget : QWidget + The main widget that contains all child components. + + """ + + 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 new file mode 100644 index 0000000000..fea5ec37d3 --- /dev/null +++ b/src/compas_viewer/components/container.py @@ -0,0 +1,138 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QScrollArea +from PySide6.QtWidgets import QSplitter +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QWidget + +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 scrollable, splitter, and standard + container modes, making it suitable for various UI layouts. + + Parameters + ---------- + 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 + ---------- + container_type : str + The type of container. + widget : QWidget, QScrollArea, or QSplitter + The main widget that contains all child components. + layout : QVBoxLayout or None + The vertical layout that arranges child components (None for splitter). + + Examples + -------- + >>> # Create a simple container + >>> container = Container() + >>> container.add(some_component) + + >>> # Create a scrollable container + >>> 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, container_type=None): + self.container_type = container_type + self.children = [] + 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) + 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.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) + self.widget.setLayout(self.layout) + + 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() + 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.""" + 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 = [] + 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.""" + self.reset() + label = QLabel(text) + label.setAlignment(Qt.AlignCenter) + label.setStyleSheet("color: gray; font-style: italic; padding: 10px;") + if self.container_type == "splitter": + self.widget.addWidget(label) + else: + self.layout.addWidget(label) 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/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/layout.py b/src/compas_viewer/components/layout.py deleted file mode 100644 index 6b29f10bc2..0000000000 --- a/src/compas_viewer/components/layout.py +++ /dev/null @@ -1,170 +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.color import ColorComboBox -from compas_viewer.components.color import ColorDialog -from compas_viewer.components.double_edit import DoubleEdit -from compas_viewer.components.label import LabelWidget -from compas_viewer.components.textedit import TextEdit - -if TYPE_CHECKING: - from compas_viewer import Viewer - - -class DefaultLayout: - """ - A class to create a default layout with minimal spacing and margins. - - Parameters - ---------- - layout : QLayout - - Attributes - ---------- - layout : QLayout - The layout with minimized spacing and margins. - """ - - def __new__(cls, layout: QLayout) -> QLayout: - layout.setSpacing(0) # Minimize the spacing between items - layout.setContentsMargins(0, 0, 0, 0) # Minimize the margins - return layout - - -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 = DoubleEdit(title=sub_title, value=value, min_val=min_val, max_val=max_val) - elif type == "label": - text = action(obj) - widget = LabelWidget(text=text, alignment="center") - elif type == "color_combobox": - widget = ColorComboBox(obj=obj, attr=attr) - elif type == "text_edit": - text = str(action(obj)) - widget = TextEdit(text=text) - elif type == "color_dialog": - widget = ColorDialog(obj=obj, attr=attr) - - if sub_title is None: - widget_name = f"{layout_title}_{type}" - else: - widget_name = f"{layout_title}_{sub_title}_{type}" - - self.widgets[widget_name] = widget - right_layout.addWidget(widget) - - sub_layout.addLayout(left_layout) - sub_layout.addLayout(right_layout) - - self.layout.addLayout(sub_layout) 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) diff --git a/src/compas_viewer/components/mainwindow.py b/src/compas_viewer/components/mainwindow.py new file mode 100644 index 0000000000..f7c9525acb --- /dev/null +++ b/src/compas_viewer/components/mainwindow.py @@ -0,0 +1,26 @@ +from PySide6.QtWidgets import QMainWindow + +from compas_viewer.components.component import Component + + +class MainWindow(Component): + def __init__(self): + super().__init__() + self.widget = QMainWindow() + self.title = self.viewer.config.window.title + + @property + def title(self): + return self.widget.windowTitle() + + @title.setter + 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/components/menubar.py similarity index 73% rename from src/compas_viewer/ui/menubar.py rename to src/compas_viewer/components/menubar.py index 6d8b304950..73466e95ce 100644 --- a/src/compas_viewer/ui/menubar.py +++ b/src/compas_viewer/components/menubar.py @@ -1,32 +1,31 @@ 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 compas_viewer.components.component import Component -if TYPE_CHECKING: - from .ui import UI +from .mainwindow import MainWindow -class MenuBar: - def __init__(self, ui: "UI", items: list[dict]) -> None: - self.ui = ui - self.items = items - self.widget: QMenuBar = self.ui.window.widget.menuBar() - self.widget.clear() - self.add_menu(items=self.items, parent=self.widget) +class MenuBar(Component): + def __init__(self, window: MainWindow) -> None: + super().__init__() + self.widget = window.widget.menuBar() + self.load_items() - def add_menu(self, *, items, parent: QMenu) -> list[QAction]: - if not items: - return + @property + def items(self): + return self.viewer.config.ui.menubar.items + + def load_items(self, items=None, parent=None) -> list[QAction]: + items = items or self.items + parent = parent or self.widget actions = [] @@ -47,7 +46,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: @@ -57,14 +56,14 @@ def add_menu(self, *, items, parent: QMenu) -> 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) @@ -75,7 +74,7 @@ def add_menu(self, *, items, parent: QMenu) -> 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/components/numberedit.py b/src/compas_viewer/components/numberedit.py new file mode 100644 index 0000000000..94893c8f73 --- /dev/null +++ b/src/compas_viewer/components/numberedit.py @@ -0,0 +1,129 @@ +from typing import Callable +from typing import Union + +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): + """ + 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 action 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. + 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 + ---------- + 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 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 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__( + 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, + action: Callable[[Component, float], None] = None, + **kwargs, + ): + super().__init__(obj, attr, action=action, **kwargs) + + 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) + 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) + 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/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 2a536befb3..5841a9db0f 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -1,167 +1,79 @@ -from typing import TYPE_CHECKING +from compas_viewer.scene import ViewerSceneObject -from PySide6.QtCore import Qt -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QDialog -from PySide6.QtWidgets import QPushButton -from PySide6.QtWidgets import QScrollArea -from PySide6.QtWidgets import QVBoxLayout -from PySide6.QtWidgets import QWidget +from .booleantoggle import BooleanToggle +from .colorpicker import ColorPicker +from .container import Container +from .numberedit import NumberEdit +from .textedit import TextEdit -from compas_viewer.base import Base -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(Container): """ - 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]): - 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.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) + def __init__(self): + super().__init__(container_type="scrollable") + + @property + def selected(self): + return [obj for obj in self.scene.objects if obj.is_selected] 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) + if len(self.selected) == 1: + self.populate(self.selected[0]) + elif len(self.selected) > 1: + self.display_text("Multiple objects selected") else: - self.scroll_layout.addWidget(LabelWidget(text="No object Selected", alignment="center")) + self.display_text("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 populate(self, obj: ViewerSceneObject) -> None: + """Populate the layout with the settings of the selected object.""" + self.reset() -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() - """ + def _update_obj_settings(*arg): + obj.update() + self.viewer.renderer.update() - 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") + def _update_obj_settings(*arg): + obj.update(update_data=True) + self.viewer.renderer.update() - if self.setting_layout is not None: - text = "Update Object" - self.layout.addLayout(self.setting_layout.layout) - else: - text = "No object selected." + def _update_sceneform(*arg): + self.viewer.ui.sidebar.sceneform.update(refresh=True) + + if hasattr(obj, "name"): + 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", action=_update_obj_settings)) + + if hasattr(obj, "show_lines"): + 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", action=_update_obj_settings)) + + 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") and obj.linecolor is not None: + self.add(ColorPicker(obj=obj, attr="linecolor", action=_update_obj_settings)) + + if hasattr(obj, "facecolor") and obj.facecolor is not None: + self.add(ColorPicker(obj=obj, attr="facecolor", action=_update_obj_settings)) - self.update_button = QPushButton(text, self) - self.update_button.clicked.connect(self.obj_update) - self.layout.addWidget(self.update_button) + if hasattr(obj, "linewidth"): + linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, action=_update_obj_settings) + self.add(linewidth_edit) - 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() + if hasattr(obj, "pointsize"): + pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, action=_update_obj_settings) + self.add(pointsize_edit) - self.accept() + if hasattr(obj, "opacity"): + 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 76f39c0356..8817b2a8e7 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. @@ -21,11 +23,13 @@ class Sceneform(QTreeWidget): 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 ---------- + widget : QTreeWidget + The tree widget for displaying the scene. scene : :class:`compas.scene.Scene` The scene to be displayed. columns : list[dict] @@ -39,47 +43,39 @@ 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__() + + 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.action = action - @property - def scene(self): - return self.viewer.scene + self.widget.itemClicked.connect(self.on_item_clicked) + self.widget.itemSelectionChanged.connect(self.on_item_selection_changed) - 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: 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 +99,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,23 +126,23 @@ 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: - self.callback(self, node) + if self.action and node.is_selected: + self.action(self, node) self.viewer.ui.sidebar.update() self.viewer.renderer.update() def on_item_selection_changed(self): - for item in self.selectedItems(): - if self.callback: - self.callback(self, item.node) + for item in self.widget.selectedItems(): + if self.action: + self.action(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/components/sidebar.py b/src/compas_viewer/components/sidebar.py new file mode 100644 index 0000000000..47b1ac01fb --- /dev/null +++ b/src/compas_viewer/components/sidebar.py @@ -0,0 +1,63 @@ +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): + """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. + tabform : Tabform + TabForm component containing ObjectSetting and CameraSetting, if they are in the items list. + object_setting : ObjectSetting + 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 + 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) + + if itemtype == "Sceneform": + columns = item.get("columns", None) + if columns is None: + raise ValueError("Please setup config for Sceneform") + self.sceneform = Sceneform(columns=columns) + self.add(self.sceneform) + + if itemtype == "ObjectSetting": + self.object_setting = ObjectSetting() + 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/sidedock.py b/src/compas_viewer/components/sidedock.py similarity index 65% rename from src/compas_viewer/ui/sidedock.py rename to src/compas_viewer/components/sidedock.py index e62ded813f..deec6d9834 100644 --- a/src/compas_viewer/ui/sidedock.py +++ b/src/compas_viewer/components/sidedock.py @@ -1,21 +1,20 @@ -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 + +from .mainwindow import MainWindow -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, window: MainWindow) -> None: + super().__init__(container_type="scrollable") self.widget = QDockWidget() self.widget.setMinimumWidth(200) self.scroll = QtWidgets.QScrollArea() @@ -28,18 +27,4 @@ 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) + window.widget.addDockWidget(self.locations["left"], self.widget) diff --git a/src/compas_viewer/components/slider.py b/src/compas_viewer/components/slider.py index 1b085f9102..aeaea8bcf9 100644 --- a/src/compas_viewer/components/slider.py +++ b/src/compas_viewer/components/slider.py @@ -1,88 +1,130 @@ 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. + **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 + ---------- + 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, + **kwargs, ): - """ - 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. + super().__init__(obj, attr, action=action, **kwargs) - 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 + 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 +133,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 +155,75 @@ 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 on_value_changed(self, value): + 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_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 + + 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 diff --git a/src/compas_viewer/components/statusbar.py b/src/compas_viewer/components/statusbar.py new file mode 100644 index 0000000000..5a3399bdab --- /dev/null +++ b/src/compas_viewer/components/statusbar.py @@ -0,0 +1,12 @@ +from PySide6.QtWidgets import QLabel + +from compas_viewer.components.component import Component + +from .mainwindow import MainWindow + + +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/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/components/textedit.py b/src/compas_viewer/components/textedit.py index 460e1a385e..746fb5d3a8 100644 --- a/src/compas_viewer/components/textedit.py +++ b/src/compas_viewer/components/textedit.py @@ -1,30 +1,88 @@ +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 .boundcomponent import BoundComponent +from .component import Component + -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 action 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. + action : 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. + 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 + 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, + action: Callable[[Component, str], None] = None, ): - super().__init__() + super().__init__(obj, attr, action=action) + 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 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 action.""" + new_text = self.text_edit.toPlainText() + self.on_value_changed(new_text) diff --git a/src/compas_viewer/ui/toolbar.py b/src/compas_viewer/components/toolbar.py similarity index 77% rename from src/compas_viewer/ui/toolbar.py rename to src/compas_viewer/components/toolbar.py index 7c4e9e57e8..75c42567eb 100644 --- a/src/compas_viewer/ui/toolbar.py +++ b/src/compas_viewer/components/toolbar.py @@ -1,29 +1,28 @@ from functools import partial -from typing import TYPE_CHECKING from typing import Any from typing import Callable from typing import Optional from compas_viewer.components import Button +from compas_viewer.components.component import Component -if TYPE_CHECKING: - from .ui import UI +from .mainwindow import MainWindow -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) -> None: + super().__init__() + self.widget = window.widget.addToolBar("Tools") self.widget.clear() self.widget.setMovable(False) self.widget.setObjectName("Tools") - self.show = show + self.load_items() - if not self.items: - return + @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) @@ -64,14 +63,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/components/viewport.py b/src/compas_viewer/components/viewport.py new file mode 100644 index 0000000000..bd354b2268 --- /dev/null +++ b/src/compas_viewer/components/viewport.py @@ -0,0 +1,47 @@ +from PySide6.QtWidgets import QSplitter + +from compas_viewer.components.component import Component +from compas_viewer.renderer import Renderer + +from .mainwindow import MainWindow +from .sidebar import SideBarRight + + +class ViewPort(Component): + def __init__(self, window: MainWindow): + super().__init__() + self.widget = QSplitter() + self.renderer = Renderer() + self.widget.addWidget(self.renderer) + self.sidebar = SideBarRight() + 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/config.py b/src/compas_viewer/config.py index cc4840757a..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 @@ -18,7 +17,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 @@ -146,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,18 +253,8 @@ 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"}, + {"type": "CameraSetting"}, ] ) @@ -337,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 @@ -412,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, @@ -431,6 +396,5 @@ class Config(ConfigBase): toggle_toolbar_cmd, zoom_selected_cmd, zoom_view_cmd, - obj_settings_cmd, ] ) 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.py b/src/compas_viewer/ui.py new file mode 100644 index 0000000000..3158bb02ee --- /dev/null +++ b/src/compas_viewer/ui.py @@ -0,0 +1,33 @@ +from compas_viewer.base import Base + +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): + def __init__(self) -> None: + self.window = MainWindow() + self.menubar = MenuBar(self.window) + self.toolbar = ToolBar(self.window) + self.statusbar = StatusBar(self.window) + 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 + + def init(self): + self.window.resize(self.viewer.config.window.width, self.viewer.config.window.height) + self.window.widget.show() + + self.sidebar.update() 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/mainwindow.py b/src/compas_viewer/ui/mainwindow.py deleted file mode 100644 index e150ec530a..0000000000 --- a/src/compas_viewer/ui/mainwindow.py +++ /dev/null @@ -1,8 +0,0 @@ -from PySide6.QtWidgets import QMainWindow - - -class MainWindow: - def __init__(self, title): - self.title = title - self.widget = QMainWindow() - self.widget.setWindowTitle(title) diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py deleted file mode 100644 index 0fe5b52f88..0000000000 --- a/src/compas_viewer/ui/sidebar.py +++ /dev/null @@ -1,74 +0,0 @@ -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 - -if TYPE_CHECKING: - from .ui import UI - - -class SideBarRight: - def __init__(self, ui: "UI", show: bool, items: list[dict[str, Callable]]) -> None: - self.ui = ui - self.widget = QSplitter(QtCore.Qt.Orientation.Vertical) - self.widget.setChildrenCollapsible(True) - self.show = show - self.hide_widget = True - self.items = items - - def add_items(self) -> None: - if not self.items: - return - - for item in self.items: - itemtype = item.get("type", None) - - if itemtype == "Sceneform": - columns = item.get("columns", None) - if columns is None: - raise ValueError("Please setup config for Sceneform") - self.sceneform = Sceneform(columns=columns) - self.widget.addWidget(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) diff --git a/src/compas_viewer/ui/statusbar.py b/src/compas_viewer/ui/statusbar.py deleted file mode 100644 index 7d7ae6a126..0000000000 --- a/src/compas_viewer/ui/statusbar.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import TYPE_CHECKING - -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) diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py deleted file mode 100644 index 7f14d951c5..0000000000 --- a/src/compas_viewer/ui/ui.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import TYPE_CHECKING - -from .mainwindow import MainWindow -from .menubar import MenuBar -from .sidebar import SideBarRight -from .sidedock import SideDock -from .statusbar import SatusBar -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 - 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( - self, - show=self.viewer.config.ui.sidebar.show, - items=self.viewer.config.ui.sidebar.items, - ) - self.viewport = ViewPort( - self, - self.viewer.renderer, - self.sidebar, - ) - self.sidedock = SideDock( - self, - show=self.viewer.config.ui.sidedock.show, - ) - # TODO: find better solution to transient window - self.sidebar.add_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() - - 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/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 diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py deleted file mode 100644 index 267ae62a98..0000000000 --- a/src/compas_viewer/ui/viewport.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import TYPE_CHECKING - -from PySide6 import QtWidgets - -if TYPE_CHECKING: - from compas_viewer.renderer import Renderer - - 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() - self.widget.addWidget(renderer) - self.widget.addWidget(sidebar.widget) - self.widget.setSizes([800, 200]) diff --git a/src/compas_viewer/viewer.py b/src/compas_viewer/viewer.py index 5dd5264130..f7cc7ea1ba 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,31 +20,20 @@ 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.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() 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) - self.unit = self.config.unit + @property + def renderer(self) -> Renderer: + return self.ui.viewport.renderer @property def scene(self) -> ViewerScene: @@ -60,30 +48,20 @@ 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