Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added click-boxes of show and locked #169

Merged
merged 23 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added default colors to `GeometryObject`.
* Added `object_info_cmd` for `compas_viewer.commends`.
* Added `gridmode` to `GridObject`.
* Added click-boxes of `show` for `compas_viewer.components.SceneForm`.
* Added observer pattern to of `show` for `compas_viewer.Scene.scene`.
* Added Time Debounce pattern to of `show` for `compas_viewer.Scene.scene`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please convert this into understandable sentences...


### Changed

Expand Down
177 changes: 81 additions & 96 deletions src/compas_viewer/components/sceneform.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
from typing import Callable
from typing import Optional

from PySide6.QtGui import QColor
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTreeWidget
from PySide6.QtWidgets import QTreeWidgetItem

from compas.scene import Scene


class Sceneform(QTreeWidget):
"""
Expand All @@ -15,128 +13,115 @@ class Sceneform(QTreeWidget):
Parameters
----------
scene : :class:`compas.scene.Scene`
The tree to be displayed. An typical example is the scene
object tree: :attr:`compas_viewer.viewer.Viewer._tree`.
columns : dict[str, callable]
The scene to be displayed.
columns : list[dict]
A dictionary of column names and their corresponding attributes.
Example: ``{"Name": (lambda o: o.name), "Object": (lambda o: o)}``
column_editable : list, optional
A list of booleans indicating whether the corresponding column is editable.
Defaults to ``[False]``.
Example: {"Name": lambda o: o.name, "Object": lambda o: o}
column_editable : list[bool], optional
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``.
stretch : int, optional
Stretch factor of the tree in the grid layout.
Defaults to ``2``.
backgrounds : dict[str, callable], optional
A dictionary of column names and their corresponding color.
Example: ``{"Object-Color": (lambda o: o.surfacecolor)}``
Show the header of the tree. Defaults to True.
callback : Callable, optional
Callback function to execute when an item is clicked or selected.

Attributes
----------
tree : :class:`compas.datastructures.Tree`
The tree to be displayed.

See Also
--------
:class:`compas.datastructures.Tree`
:class:`compas.datastructures.tree.TreeNode`
:class:`compas_viewer.layout.SidedockLayout`

References
----------
:PySide6:`PySide6/QtWidgets/QTreeWidget`

Examples
--------
.. code-block:: python

from compas_viewer import Viewer

viewer = Viewer()

for i in range(10):
for j in range(10):
sp = viewer.scene.add(Sphere(0.1, Frame([i, j, 0], [1, 0, 0], [0, 1, 0])), name=f"Sphere_{i}_{j}")

viewer.layout.sidedock.add_element(Treeform(viewer._tree, {"Name": (lambda o: o.object.name), "Object": (lambda o: o.object)}))

viewer.show()

scene : :class:`compas.scene.Scene`
The scene to be displayed.
columns : list[dict]
A dictionary of column names and their corresponding function.
checkbox_columns : dict[int, dict[str, Callable]]
A dictionary of column indices and their corresponding attributes.
"""

def __init__(
self,
scene: Scene,
columns: dict[str, Callable],
column_editable: list[bool] = [False],
columns: list[dict],
column_editable: Optional[list[bool]] = None,
show_headers: bool = True,
stretch: int = 2,
backgrounds: Optional[dict[str, Callable]] = None,
callback: Optional[Callable] = None,
):
super().__init__()
self.columns = columns
self.column_editable = column_editable + [False] * (len(columns) - len(column_editable))
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(list(self.columns.keys()))
self.setHeaderLabels(col["title"] for col in self.columns)
self.setHeaderHidden(not show_headers)
self.stretch = stretch
self._backgrounds = backgrounds

self.scene = scene
self.callback = callback
self.itemClicked.connect(self.on_item_clickded)

self.itemClicked.connect(self.on_item_clicked)
self.itemSelectionChanged.connect(self.on_item_selection_changed)

@property
def scene(self) -> Scene:
return self._scene

@scene.setter
def scene(self, scene: Scene):
self.clear()
for node in scene.traverse("breadthfirst"):
if node.is_root:
continue

strings = [str(c(node)) for _, c in self.columns.items()]

if node.parent.is_root: # type: ignore
node.attributes["widget"] = QTreeWidgetItem(self, strings) # type: ignore
else:
node.attributes["widget"] = QTreeWidgetItem(
node.parent.attributes["widget"],
strings, # type: ignore
)

node.attributes["widget"].node = node
node.attributes["widget"].setSelected(node.is_selected)
def viewer(self):
from compas_viewer import Viewer

if self._backgrounds:
for col, background in self._backgrounds.items():
node.attributes["widget"].setBackground(list(self.columns.keys()).index(col), QColor(*background(node).rgb255))
return Viewer()

self._scene = scene
@property
def scene(self):
return self.viewer.scene

def update(self):
from compas_viewer import Viewer

self.scene = Viewer().scene

def on_item_clickded(self):
selected_nodes = [item.node for item in self.selectedItems()]
for node in self.scene.objects:
node.is_selected = node in selected_nodes
if self.callback and node.is_selected:
self.callback(node)
self.clear()
self.checkbox_columns = {}

from compas_viewer import Viewer
for node in self.scene.traverse("breadthfirst"):
if node.is_root:
continue

Viewer().renderer.update()
strings = []

for i, column in enumerate(self.columns):
type = column.get("type", None)
if type == "checkbox":
action = column.get("action")
checked = column.get("checked")
if not action or not checked:
raise ValueError("Both action and checked must be provided for checkbox")
self.checkbox_columns[i] = {"action": action, "checked": checked}
strings.append("")
elif type == "label":
text = column.get("text")
if not text:
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"]
widget = QTreeWidgetItem(parent_widget, strings)
widget.node = node
widget.setSelected(node.is_selected)
widget.setFlags(widget.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled)

for col, col_data in self.checkbox_columns.items():
widget.setCheckState(col, Qt.Checked if col_data["action"](node) else Qt.Unchecked)

node.attributes["widget"] = widget

self.adjust_column_widths()

def on_item_clicked(self, item, column):
if column in self.checkbox_columns:
check = self.checkbox_columns[column]["checked"]
check(item.node, item.checkState(column) == Qt.Checked)

if self.selectedItems():
selected_nodes = {item.node for item in self.selectedItems()}
for node in self.scene.objects:
node.is_selected = node in selected_nodes
if self.callback and node.is_selected:
self.callback(node)

self.viewer.renderer.update()

def on_item_selection_changed(self):
for item in self.selectedItems():
if self.callback:
self.callback(item.node)

def adjust_column_widths(self):
for i in range(self.columnCount()):
if i in self.checkbox_columns:
self.setColumnWidth(i, 50)
12 changes: 11 additions & 1 deletion src/compas_viewer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,17 @@ class StatusbarConfig(ConfigBase):
class SidebarConfig(ConfigBase):
show: bool = True
sceneform: bool = True
items: list[dict[str, str]] = None
items: list[dict] = field(
default_factory=lambda: [
{
"type": "Sceneform",
"columns": [
{"title": "Name", "type": "label", "text": lambda obj: obj.name},
{"title": "Show", "type": "checkbox", "action": lambda obj: obj.show, "checked": lambda obj, checked: setattr(obj, "show", checked)},
],
},
]
)


# =============================================================================
Expand Down
4 changes: 3 additions & 1 deletion src/compas_viewer/scene/bufferobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def __init__(
opacity: Optional[float] = None,
doublesided: Optional[bool] = None,
is_visiable: Optional[bool] = None,
is_locked: Optional[bool] = None,
**kwargs,
):
super().__init__(**kwargs)
Expand All @@ -206,7 +207,8 @@ def __init__(
self.linewidth = 1.0 if linewidth is None else linewidth
self.opacity = 1.0 if opacity is None else opacity
self.doublesided = True if doublesided is None else doublesided
self.is_visible = True if is_visiable is None else is_visiable
self.show = True if is_visiable is None else is_visiable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_visiable => is_visible

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, i am not a fan of self.show. i think it should be self.is_visible. self.show sounds like a method...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

show comes from compas core, which I suggested a while ago for the simplicity, but I agree indeed it can feel like a method. If we decide to go back to is_visible, I would suggest we make this change in compas core first then update here

self._is_locked = False if is_locked is None else is_locked
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

were we not going to remove this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes we are, but it would be better to do that in another PR, cuz this attribute is a bit everywhere, this one here is added temporarily to make the form work


self.is_selected = False
self.background = False
Expand Down
60 changes: 59 additions & 1 deletion src/compas_viewer/scene/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import Optional
from typing import Union

from PySide6.QtCore import QTimer

from compas.colors import Color
from compas.datastructures import Datastructure
from compas.geometry import Geometry
Expand Down Expand Up @@ -60,6 +62,15 @@ class ViewerScene(Scene):
context : str, optional
The context of the scene.

Attributes
----------
observers : set
Set of unique observer objects.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these objects that observe, or objects that are being observed?

what makes something an "observer"?

is there a base class somewhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the observer class in PR #181 maybe I remove from here, so is less confusing

update_timer : :class:`QTimer`
Timer to manage update debouncing.
debounce_interval : int
Interval in milliseconds for debouncing updates.

See Also
--------
:class:`compas.scene.Scene`
Expand All @@ -70,10 +81,35 @@ def __init__(self, name: str = "ViewerScene", context: str = "Viewer"):

# Primitive
self.objects: list[ViewerSceneObject]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, this does not initialize the attribute. it just adds a type annotation. if you try to access this attribute an error will be thrown. it should be self.objects: list[ViewerSceneObject] = []

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objects is inherent from the parent class, I believe this was only for additionally type-hinting the object will be ViewerScenceObject instead of the base SceneObject


self._observers = set()
# Selection
self.instance_colors: dict[tuple[int, int, int], ViewerSceneObject] = {}
self._instance_colors_generator = instance_colors_generator()
# Time Debounce
self._time = None
self.update_timer = QTimer()
self.update_timer.setSingleShot(True)
self.update_timer.timeout.connect(self.update_observers)
self.debounce_interval = 200

@property
def viewer(self):
from compas_viewer import Viewer

return Viewer()

# TODO: This property will be updated from #181 PR.
@property
def observers(self):
new_observers = [
self.viewer.renderer,
self.viewer.ui.sidebar,
]

for observer in new_observers:
self._observers.add(observer)

return list(self._observers)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks a bit fishy...


# TODO: These fixed kwargs could be moved to COMPAS core.
def add(
Expand Down Expand Up @@ -179,4 +215,26 @@ def add(
**kwargs,
)

self.request_update()
return sceneobject

def remove(self, item: ViewerSceneObject) -> None:
"""
Remove an item from the scene.

Parameters
----------
item : :class:`compas_viewer.scene.ViewerSceneObject`
The item to remove.
"""

super().remove(item)
self.request_update()

def request_update(self):
if not self.update_timer.isActive():
self.update_timer.start(self.debounce_interval)

def update_observers(self):
for observer in self.observers:
observer.update()
23 changes: 20 additions & 3 deletions src/compas_viewer/ui/sidebar.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from typing import TYPE_CHECKING
from typing import Callable

from PySide6 import QtCore
from PySide6 import QtWidgets
from PySide6.QtWidgets import QSplitter

from compas_viewer.components import Sceneform
from compas_viewer.components.objectsetting import ObjectSetting

if TYPE_CHECKING:
Expand All @@ -14,11 +16,26 @@ def is_layout_empty(layout):


class SideBarRight:
def __init__(self, ui: "UI", show: bool = True) -> None:
def __init__(self, ui: "UI", show: bool, items: list[dict[str, Callable]]) -> None:
self.ui = ui
self.widget = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
self.widget = QSplitter(QtCore.Qt.Orientation.Vertical)
self.widget.setChildrenCollapsible(True)
self.show = show
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 not None:
self.widget.addWidget(Sceneform(columns))
else:
raise ValueError("Columns not provided for Sceneform")

def update(self):
self.widget.update()
Expand Down
Loading
Loading