From 7f5769189fd13664b27bf17f426623ed0678c616 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sat, 15 Jun 2024 18:32:33 +0200 Subject: [PATCH 01/26] Switch to PySide6 --- requirements_gui.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_gui.txt b/requirements_gui.txt index 1f5b19637..10077ad0c 100644 --- a/requirements_gui.txt +++ b/requirements_gui.txt @@ -2,4 +2,4 @@ PySide6==6.7.1;python_version>"3.11" PySide6==6.5.3;python_version=="3.11" PySide2==5.15.2.1;python_version<="3.10" QtPy==1.11.3;python_version<"3.7" -QtPy==2.4.1;python_version>="3.7" +QtPy==2.3.0;python_version>="3.7" From 66eee0071d64d1a6bac5a3c5be17d8b313a969f3 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sat, 15 Jun 2024 18:43:54 +0200 Subject: [PATCH 02/26] Update qtpy to 2.4.1 --- requirements_gui.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_gui.txt b/requirements_gui.txt index 10077ad0c..1f5b19637 100644 --- a/requirements_gui.txt +++ b/requirements_gui.txt @@ -2,4 +2,4 @@ PySide6==6.7.1;python_version>"3.11" PySide6==6.5.3;python_version=="3.11" PySide2==5.15.2.1;python_version<="3.10" QtPy==1.11.3;python_version<"3.7" -QtPy==2.3.0;python_version>="3.7" +QtPy==2.4.1;python_version>="3.7" From 33dfa160e1fe9201d2c9201c0654c48348c7553a Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sat, 15 Jun 2024 19:01:16 +0200 Subject: [PATCH 03/26] Update PySide2/PySide6 support matrix --- requirements_gui.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_gui.txt b/requirements_gui.txt index 1f5b19637..76129b277 100644 --- a/requirements_gui.txt +++ b/requirements_gui.txt @@ -2,4 +2,4 @@ PySide6==6.7.1;python_version>"3.11" PySide6==6.5.3;python_version=="3.11" PySide2==5.15.2.1;python_version<="3.10" QtPy==1.11.3;python_version<"3.7" -QtPy==2.4.1;python_version>="3.7" +QtPy==2.4.1;python_version>="3.7" \ No newline at end of file From 0f8ee7e56d45654ad3ef6272ef9462a7dfaf6009 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sat, 29 Jun 2024 21:10:20 +0200 Subject: [PATCH 04/26] Update code to use qtpy and fork of NodeGraphQt that supports PySide6 --- cuegui/cuegui/AbstractGraphWidget.py | 126 +++++++++++++++ cuegui/cuegui/App.py | 1 + cuegui/cuegui/JobMonitorGraph.py | 153 ++++++++++++++++++ cuegui/cuegui/LayerMonitorTree.py | 50 +++++- cuegui/cuegui/images/apps/blender.png | Bin 0 -> 5663 bytes cuegui/cuegui/images/apps/ffmpeg.png | Bin 0 -> 6791 bytes cuegui/cuegui/images/apps/gaffer.png | Bin 0 -> 3404 bytes cuegui/cuegui/images/apps/krita.png | Bin 0 -> 9406 bytes cuegui/cuegui/images/apps/natron.png | Bin 0 -> 4285 bytes cuegui/cuegui/images/apps/oiio.png | Bin 0 -> 2679 bytes cuegui/cuegui/images/apps/placeholder.png | Bin 0 -> 695 bytes cuegui/cuegui/images/apps/postprocess.png | Bin 0 -> 65812 bytes cuegui/cuegui/images/apps/rm.png | Bin 0 -> 1499 bytes cuegui/cuegui/images/apps/shell.png | Bin 0 -> 5422 bytes cuegui/cuegui/images/apps/terminal.png | Bin 0 -> 5422 bytes cuegui/cuegui/nodegraph/__init__.py | 21 +++ cuegui/cuegui/nodegraph/nodes/__init__.py | 19 +++ cuegui/cuegui/nodegraph/nodes/base.py | 80 +++++++++ cuegui/cuegui/nodegraph/nodes/layer.py | 95 +++++++++++ cuegui/cuegui/nodegraph/widgets/__init__.py | 1 + .../cuegui/nodegraph/widgets/nodeWidgets.py | 112 +++++++++++++ .../cuegui/plugins/MonitorJobGraphPlugin.py | 102 ++++++++++++ pycue/opencue/wrappers/layer.py | 7 + requirements_gui.txt | 2 +- 24 files changed, 764 insertions(+), 5 deletions(-) create mode 100644 cuegui/cuegui/AbstractGraphWidget.py create mode 100644 cuegui/cuegui/JobMonitorGraph.py create mode 100644 cuegui/cuegui/images/apps/blender.png create mode 100644 cuegui/cuegui/images/apps/ffmpeg.png create mode 100644 cuegui/cuegui/images/apps/gaffer.png create mode 100644 cuegui/cuegui/images/apps/krita.png create mode 100644 cuegui/cuegui/images/apps/natron.png create mode 100644 cuegui/cuegui/images/apps/oiio.png create mode 100644 cuegui/cuegui/images/apps/placeholder.png create mode 100644 cuegui/cuegui/images/apps/postprocess.png create mode 100644 cuegui/cuegui/images/apps/rm.png create mode 100644 cuegui/cuegui/images/apps/shell.png create mode 100644 cuegui/cuegui/images/apps/terminal.png create mode 100644 cuegui/cuegui/nodegraph/__init__.py create mode 100644 cuegui/cuegui/nodegraph/nodes/__init__.py create mode 100644 cuegui/cuegui/nodegraph/nodes/base.py create mode 100644 cuegui/cuegui/nodegraph/nodes/layer.py create mode 100644 cuegui/cuegui/nodegraph/widgets/__init__.py create mode 100644 cuegui/cuegui/nodegraph/widgets/nodeWidgets.py create mode 100644 cuegui/cuegui/plugins/MonitorJobGraphPlugin.py diff --git a/cuegui/cuegui/AbstractGraphWidget.py b/cuegui/cuegui/AbstractGraphWidget.py new file mode 100644 index 000000000..cc63f6580 --- /dev/null +++ b/cuegui/cuegui/AbstractGraphWidget.py @@ -0,0 +1,126 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Base class for CueGUI graph widgets.""" + +from qtpy import QtCore +from qtpy import QtGui +from qtpy import QtWidgets + +from NodeGraphQt import NodeGraph +from NodeGraphQt.errors import NodeRegistrationError +from cuegui.nodegraph import CueLayerNode +from cuegui import app + + +class AbstractGraphWidget(QtWidgets.QWidget): + """Base class for CueGUI graph widgets""" + + def __init__(self, parent=None): + super(AbstractGraphWidget, self).__init__(parent=parent) + self.setupUI() + + self.timer = QtCore.QTimer(self) + self.timer.timeout.connect(self.update) + self.timer.setInterval(1000 * 20) + + self.graph.node_selection_changed.connect(self.onNodeSelectionChanged) + app().quit.connect(self.timer.stop) + + def setupUI(self): + """Setup the UI.""" + self.graph = NodeGraph() + try: + self.graph.register_node(CueLayerNode) + except NodeRegistrationError: + pass + self.graph.viewer().installEventFilter(self) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.graph.viewer()) + + def onNodeSelectionChanged(self): + """Slot run when a node is selected. + + Updates the nodes to ensure they're visualising current data. + Can be used to notify other widgets of object selection. + """ + self.update() + + def handleSelectObjects(self, rpcObjects): + """Select incoming objects in graph. + """ + received = [o.name() for o in rpcObjects] + current = [rpcObject.name() for rpcObject in self.selectedObjects()] + if received == current: + # prevent recursing + return + + for node in self.graph.all_nodes(): + node.set_selected(False) + for rpcObject in rpcObjects: + node = self.graph.get_node_by_name(rpcObject.name()) + node.set_selected(True) + + def selectedObjects(self): + """Return the selected nodes rpcObjects in the graph. + :rtype: [opencue.wrappers.layer.Layer] + :return: List of selected layers + """ + rpcObjects = [n.rpcObject for n in self.graph.selected_nodes()] + return rpcObjects + + def eventFilter(self, target, event): + """Override eventFilter + + Centre nodes in graph viewer on 'F' key press. + + @param target: widget event occurred on + @type target: QtWidgets.QWidget + @param event: Qt event + @type event: QtCore.QEvent + """ + if hasattr(self, "graph"): + viewer = self.graph.viewer() + if target == viewer: + if event.type() == QtCore.QEvent.KeyPress: + if event.key() == QtCore.Qt.Key_F: + self.graph.center_on() + if event.key() == QtCore.Qt.Key_L: + self.graph.auto_layout_nodes() + + return super(AbstractGraphWidget, self).eventFilter(target, event) + + def clearGraph(self): + """Clear all nodes from the graph + """ + for node in self.graph.all_nodes(): + for port in node.output_ports(): + port.unlock() + for port in node.input_ports(): + port.unlock() + self.graph.clear_session() + + def createGraph(self): + """Create the graph to visualise OpenCue objects + """ + raise NotImplementedError() + + def update(self): + """Update nodes with latest data + + This is run every 20 seconds by the timer. + """ + raise NotImplementedError() diff --git a/cuegui/cuegui/App.py b/cuegui/cuegui/App.py index 8e5409520..eaf48c500 100644 --- a/cuegui/cuegui/App.py +++ b/cuegui/cuegui/App.py @@ -40,6 +40,7 @@ class CueGuiApplication(QtWidgets.QApplication): request_update = QtCore.Signal() status = QtCore.Signal() quit = QtCore.Signal() + select_layers = QtCore.Signal(list) # Thread pool threadpool = None diff --git a/cuegui/cuegui/JobMonitorGraph.py b/cuegui/cuegui/JobMonitorGraph.py new file mode 100644 index 000000000..66b1b72c5 --- /dev/null +++ b/cuegui/cuegui/JobMonitorGraph.py @@ -0,0 +1,153 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Node graph to display Layers of a Job""" + + +from qtpy import QtGui +from qtpy import QtWidgets + +import cuegui.Utils +import cuegui.MenuActions +from cuegui.nodegraph import CueLayerNode +from cuegui.AbstractGraphWidget import AbstractGraphWidget + + +class JobMonitorGraph(AbstractGraphWidget): + """Graph widget to display connections of layers in a job""" + + def __init__(self, parent=None): + super(JobMonitorGraph, self).__init__(parent=parent) + self.job = None + self.setupContextMenu() + + # wire signals + cuegui.app().select_layers.connect(self.handleSelectObjects) + + def onNodeSelectionChanged(self): + """Notify other widgets of Layer selection. + + Emit signal to notify other widgets of Layer selection, this keeps + all widgets with selectable Layers in sync with each other. + + Also force updates the nodes, as the timed updates are infrequent. + """ + self.update() + layers = self.selectedObjects() + cuegui.app().select_layers.emit(layers) + + def setupContextMenu(self): + """Setup context menu for nodes in node graph""" + self.__menuActions = cuegui.MenuActions.MenuActions( + self, self.update, self.selectedObjects, self.getJob + ) + + menu = self.graph.context_menu().qmenu + + dependMenu = QtWidgets.QMenu("&Dependencies", self) + self.__menuActions.layers().addAction(dependMenu, "viewDepends") + self.__menuActions.layers().addAction(dependMenu, "dependWizard") + dependMenu.addSeparator() + self.__menuActions.layers().addAction(dependMenu, "markdone") + menu.addMenu(dependMenu) + menu.addSeparator() + self.__menuActions.layers().addAction(menu, "useLocalCores") + self.__menuActions.layers().addAction(menu, "reorder") + self.__menuActions.layers().addAction(menu, "stagger") + menu.addSeparator() + self.__menuActions.layers().addAction(menu, "setProperties") + menu.addSeparator() + self.__menuActions.layers().addAction(menu, "kill") + self.__menuActions.layers().addAction(menu, "eat") + self.__menuActions.layers().addAction(menu, "retry") + menu.addSeparator() + self.__menuActions.layers().addAction(menu, "retryDead") + + def setJob(self, job): + """Set Job to be displayed + @param job: Job to display as node graph + @type job: opencue.wrappers.job.Job + """ + self.timer.stop() + self.clearGraph() + + if job is None: + self.job = None + return + + job = cuegui.Utils.findJob(job) + self.job = job + self.createGraph() + self.timer.start() + + def getJob(self): + """Return the currently set job + :rtype: opencue.wrappers.job.Job + :return: Currently set job + """ + return self.job + + def selectedObjects(self): + """Return the selected Layer rpcObjects in the graph. + :rtype: [opencue.wrappers.layer.Layer] + :return: List of selected layers + """ + layers = [n.rpcObject for n in self.graph.selected_nodes() if isinstance(n, CueLayerNode)] + return layers + + def createGraph(self): + """Create the graph to visualise the grid job submission + """ + if not self.job: + return + + layers = self.job.getLayers() + + # add job layers to tree + for layer in layers: + node = CueLayerNode(layer) + self.graph.add_node(node) + node.set_name(layer.name()) + + # setup connections + self.setupNodeConnections() + + self.graph.auto_layout_nodes() + self.graph.center_on() + + def setupNodeConnections(self): + """Setup connections between nodes based on their dependencies""" + for node in self.graph.all_nodes(): + for depend in node.rpcObject.getWhatDependsOnThis(): + child_node = self.graph.get_node_by_name(depend.dependErLayer()) + if child_node: + # todo check if connection exists + child_node.set_input(0, node.output(0)) + + for node in self.graph.all_nodes(): + for port in node.output_ports(): + port.lock() + for port in node.input_ports(): + port.lock() + + def update(self): + """Update nodes with latest Layer data + + This is run every 20 seconds by the timer. + """ + layers = self.job.getLayers() + for layer in layers: + node = self.graph.get_node_by_name(layer.name()) + node.setRpcObject(layer) diff --git a/cuegui/cuegui/LayerMonitorTree.py b/cuegui/cuegui/LayerMonitorTree.py index 25916e16f..2c91b23fe 100644 --- a/cuegui/cuegui/LayerMonitorTree.py +++ b/cuegui/cuegui/LayerMonitorTree.py @@ -22,6 +22,7 @@ from qtpy import QtCore from qtpy import QtWidgets +from qtpy import QtGui from opencue.exception import EntityNotFoundException @@ -143,7 +144,8 @@ def __init__(self, parent): tip="Timeout for a frames\' LLU, Hours:Minutes") cuegui.AbstractTreeWidget.AbstractTreeWidget.__init__(self, parent) - self.itemDoubleClicked.connect(self.__itemDoubleClickedFilterLayer) + self.itemSelectionChanged.connect(self.__itemSelectionChangedFilterLayer) + cuegui.app().select_layers.connect(self.__handle_select_layers) # Used to build right click context menus self.__menuActions = cuegui.MenuActions.MenuActions( @@ -249,9 +251,49 @@ def contextMenuEvent(self, e): menu.exec_(e.globalPos()) - def __itemDoubleClickedFilterLayer(self, item, col): - del col - self.handle_filter_layers_byLayer.emit([item.rpcObject.data.name]) + def __itemSelectionChangedFilterLayer(self): + """Filter FrameMonitor to selected Layers. + Emits signal to filter FrameMonitor to selected Layers. + Also emits signal for other widgets to select Layers. + """ + layers = self.selectedObjects() + layer_names = [layer.data.name for layer in layers] + + # emit signal to filter Frame Monitor + self.handle_filter_layers_byLayer.emit(layer_names) + + # emit signal to select Layers in other widgets + cuegui.app().select_layers.emit(layers) + + def __handle_select_layers(self, layerRpcObjects): + '''Select incoming Layers in tree. + Slot connected to QtGui.qApp.select_layers inorder to handle + selecting Layers in Tree. + Also emits signal to filter FrameMonitor + ''' + received_layers = [l.data.name for l in layerRpcObjects] + current_layers = [l.data.name for l in self.selectedObjects()] + if received_layers == current_layers: + # prevent recursion + return + + # prevent unnecessary calls to __itemSelectionChangedFilterLayer + self.blockSignals(True) + try: + for item in self._items.values(): + item.setSelected(False) + for layer in layerRpcObjects: + objectKey = cuegui.Utils.getObjectKey(layer) + if objectKey not in self._items: + self.addObject(layer) + item = self._items[objectKey] + item.setSelected(True) + finally: + # make sure signals are re-enabled + self.blockSignals(False) + + # emit signal to filter Frame Monitor + self.handle_filter_layers_byLayer.emit(received_layers) class LayerWidgetItem(cuegui.AbstractWidgetItem.AbstractWidgetItem): diff --git a/cuegui/cuegui/images/apps/blender.png b/cuegui/cuegui/images/apps/blender.png new file mode 100644 index 0000000000000000000000000000000000000000..c32f78a085ffd91113eec88e71f93c70ddcd175c GIT binary patch literal 5663 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 WV&enrxBZV$3s6(i23IIp2mcop#MpNL literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/ffmpeg.png b/cuegui/cuegui/images/apps/ffmpeg.png new file mode 100644 index 0000000000000000000000000000000000000000..1c1644247dbd5ca90a3cd82b5b5865cb1a129c70 GIT binary patch literal 6791 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 KU!z(L6ZJo<`-E%& literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/gaffer.png b/cuegui/cuegui/images/apps/gaffer.png new file mode 100644 index 0000000000000000000000000000000000000000..293bf70286dbd2b2121ea4f97e8226791d890330 GIT binary patch literal 3404 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 NFf+0;e5UUa_aDY)H$VUY literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/krita.png b/cuegui/cuegui/images/apps/krita.png new file mode 100644 index 0000000000000000000000000000000000000000..7515f0b70c1386a92cc81c85ade7d074eb564939 GIT binary patch literal 9406 zcma)iWl$VU(B=XQEWSW+Tijuh051|)B)Ge~1lK?yB(N;*L4v!xCb%sYNRZ%e3GQxz z8_sXsj2Fo>PQVWc|2@NYybd&r>G#S^@3I}WCOx@nFAmH62Ab@ z9;ONd0P5p$9xc&dcoZ)!d1*k+81>!@1F=@nQUw6~Spa|_1ORaVA`03C0DPbTz`g|l zAesgMkhy2IX^OurU|1>3%L1PNXXJI3CcQ|o+!YMHUR=-p51~l2W%>aCjB$#xFde_e zqwH{FUHjSVwa=E@0o@<$`4v@EtQ6DxNv&Z(Ie#)RJEmGFTDZ(fry{RVG6M+on*_Gk zd9w-wzdp)7S3SA;o)d_#6!U|LBHMx|=b){&aRU``wRB*1U>J~l^x4wOCF06FlYSmp z-zo%ihR>U1LSC>&75sv8EDdnZ-|C_fpC7mA(lRq; ztgRVINl9Dch39SnRu&e3_wV0N%*|oO4 zz|;L)_vVzky8ZRtM#D{K% zNSKOivEf2i6T9wep7r<3uNo{Lgrux=wa6Q-$Q9^cpPkd1|2(?r-9_Q`raqGmE#Vx? z92OCY`$&Hy5D0R9uO?JdTG|@(0P|L@G8naK;O(`biy~a-dp}#Loq2Pur!Nr zZ?W=M=zssUS~?%BIhrrf^}(M6$+x$+het<|C!cQGc>~v8E=;|q$Ijj!nQkEp4gygL zn@CcGz5nO;lLmJK{&VcSJ>a84S61q7+LW1Gg5mqBcQAGgH&w5ZIrP70>-4;@oZ3Cp zSy&)E$uoC%cel4&@#c?#vcA5;RaI5AGm%$9wiXsaySolNy9X|1t*sPYpFU;XU|#%T zV*zB}NA6CkZVOh>s`g&|MHT5j8SS;r_;*@w_vVu2&OM{VTJ52euUReWAPy6xkQm4L z=u8cHg+(YO*Hu`!70_JynF=V@?xv-W6fI@7tg^*ygwCEf;BW8GRJ!M(a_xvgrWgR# z7+AYg%CK59et(}Nkt?SG>-``}vh`AQAMU!WVSkonO7l{_5;W{s&+|<)`B|FpmHrH| z`w_(E!16|7Y#f}k%(chuffd8X)qj1VIO^#27|6v%N?KZ4Fitt>!o*{gNr~{$2jK5R zJXtA|o}UzySeS2)dl>xs13OwJXcsgE!#;)a;?_=0i1I00Q5sCae7{i91QeXqktzMC z`RKwf*3ZDi%IcN9gd?_TK~)_T6vR6deWh2d+#nzk>xp!~^fIi&2Vl}~B;KpbTHhv& z>tkH~SijcRcwjG62@ID=IhW?`^wc`r>~E{hK_2h^2a&!(!D=ID&(C}_SFsV-sHZi? zQ&>>Y61#~b=9H0}8*ae$q(tuYW!HG4&1^y&($85$6K+;*a9Muug>t+Ff3VSzAc5&t z1gK88y=R|kRinugFQdBn!&PoNKygI3kaxsL8`#a58sBcMI7Fjgl?BO{rllr=4-5>D z`R>lm&SK!;MAtU4^N3wuxht)`+PPew$fp{D?a~9XK+^H=tE7iyCX3HS>f%EV@mw`#C}avBJ>c%Y^S4UeML@3Ao$l{(if>q@1v{J&&a^o zDlvtF1wW!OSaNi5xNSDfm*@X2EE|zSe$}RF!s30<$rAb-&>vPde3Rp8->cDDvx;9-j6{b3Ri})5+WiZ zk)=TTp>^kENQ%;Pv0U{J!*PtiZ7^yCPd2%?W3DC#W%UpmTiJ0uTIID!9^OdMEN6)E zkoRG|<6L=ItwWe}RyQ7_fo-neYoNzlUw(ie3(m4TC?n&7EouOtbU{&Uq zJwCj-(f4?HL&*Q_+qY{VU?T@rpn%Q(m(E?;!VpLkG2;CZTa!bWhK58A<@8Xn&z5&) zTzbV`OGZ?!v}@VspSTKQ*A}^xVHK(t%ibRJXjU#hC&wVgNPIi$W}D=<77y*A8rYfivXfO2&j}$wRDV6^h4fQ`x)^XdXg7u zOULPhzsuk7a9$Ay+D;Pj%c0A<#zA!SF!*$7ZN>yo+ z)TN(+We@}xC(hc$k6OFPaD$IabkCDi_duo@ZnTK#mwntI_&nbNF%VLqW;*r2mM!tc zYWT?MK5&9TWUFtH=ZWn|_wLh{e?9HsJJ;?FIJQK$Jj$4HQWtgWf%jFSNrE**e*}G@ zh=`HQn2v(ERI>-iwhVH z<|Xb;Z0mh`dWyw@XVagZosBrRAe?it@qw(*R3MTPsVx~aYW+!gSI*P-Z|r*gO>nEF zOL9?x0ly&RHKo$--tnY@Gu~Y!5wFQvfQ%D8w0mLvJS{4RR`Jq2Sv*vu$(|cEhsBBf zDmE>0;0$%KEqoYTXn!+jLZdSfk$;HYIewtVL@2sou^ z30R<)PQnc=Fnv$7A|yXy9{>#LU?gW&O|U!Ef%47_c-8^tu!=O;HjhmfG$8YjZv>fu zR`$XzrtiY+Hy|A~S4gq$Tr{Gb*|4H{(IU-MruH4A>vG_Zi~IGY zC7iX&&eQSZx4Xso{j9I)`?s*0bboqyce8l=bW!0FqsiQ6o+dhP$1rIzB(+LoZL4MR zWO=@gj3vfq!f)ARk;V@mW(9}RMpd!h+!x5b21nSXQ!G~D=SNQ~Q`WsfXlBYJ6JmPk z`+*2hf4NJhin98?PoE+2Jy~55fy@q4s40GM=cEh^*Z_#vvP<0(fl^7H79>C|zhir< zcn~GN()&p$Gsr;=u_D#%as)<261i85fGRt^k5 z+_yZvErShkor16&``Q_!xc4n6_jY%^(GMjG z4FQfESmqX`LIq|vHMG+slJ6TX9fy^}HnhYQD1#pTC6}@NH&W)%*0NmjWi{-`-~tMJ zG<80zgP%wksco&nohKN`!C~BuQfjbVuzgfhGQ)k-tFO+*3ap?Vll~Sd2u~7!iebH- z=D-1W)Bn}zzEZMeU0axRVHV~DK#A%wk(TXz)a_J-itW(EZ)5_d zCFE2a^gZS;*`CznOFi`T1gldnXRD@qHf6f0;R7gpBG}mn#kagTKdmlb*qqBJ81B7$^rD4(1a6f**-zkj6=iMK0*A9 zg7M`|kHZLNn)ow{UFId9*Z?x!0ma00#p7hP^MtN{ex96~hI*ac2c>2Jsl!MfK`-)^ zC_;-l;`bX74Lt>=HhT7BL`zw=1vmoE0>VN-Ye~Po8xZMFvf4=`7Aea|UvV=Kn9ttL z&(DvD7Y_lK6QdS0AixozRKjGGo!!H6ri0eE_Mbhf0L(U$1Himp)FaH_cP%-;D1I3 z+yVpkyij`Wc<}!W{ccfdNsUS1uEyj8y;}eT=hd?7!rAuVEiGsT1$rQyO!|)5o?!6y zDm}TJM!oM~93$oJX&OsYw;j)btI5UXr7R4FQc+RielW{jRLn#_9Lf(rWKHdmOb2A) zk!_j?va1DMB%_lJ!+A{qFeX9%fDuj~uGu)6Hp4=LlYg2zCfbXpq*J0QEMDf?gciAUszai?(;d{d!<&-^fp14lg_|Hz=-#XO4c47%^3kYqHHK79HPFX`?!K%hu@{PL+(OyLys0dqN|K(G@bg z?mIzojqlI6HfV9VL>?VTrH$og&EvtES`lY?Rl|KFAcPz7+Q~C!Tq}r;Cd@0HNcIjK zOs*5eb`svwK~~T}8^OkjNG5@A3W1_BdU>6L{xuuT|J4ots*SQ#Wo2MK5*nutUY(jA z%zTjS3kb*GsJrKLaB%b##{W#%W6dXGYdesP(iH(B)_@h8LKn2K7k+;z=C)1bHtm%D zmORR8yjghoh0xW%J9^2?t#ItdMG*Z^)_6kAJM>uAyk8Ah{;1#Yr<%&pN&m+_e6Bf% z^`{1IdpD}VjM=DWnZv(6kgyG%Hy!x>5GIm;VBJfY#w6Oqx!UQ-l7;CaaCDQ>z@!#& z*UX=0%8fA8>*UShB@fU)F zPie>_m*XM@miUOj(?nw})&DMweBk^BOeNTR%Rajtz*H9K15(v!ssZ+uTakasWCZKr zA+N{4YIC?f9W`VuKBmwrmyq6UmR1VswxFfE8buGAW(dD3HoI#53dDQIekcXZa zKY301${Wcn2y;O$!UTr`f_-Bl;irSgEA!O_@Vp53<*KTWQF81SCfzw9f0!t~5Zz{R zn~Fcn8FT#nF0)7RdO5*OKIP&4w4bYd$oTuFdlwn|b)icPH>Ab9P>h@_(&kd==YT6x zt50azX{uMEp~SC2ymc9mlarImMrP+PI!ub3D|66tSpcW=$_579uSIMoXuI$y|Tn&=xhEk>0WZ*{(C$v2o|Kj4E zNQysxsv^6H4-3ZI)i)xp<3naH+#R+4@lgLH zaQ_uBR+^MaA5~lS{($q{bWJ*Y$k^fBAIGK3Er0Zx`x3n~bYtQ>l=g|Gq;R$WFrd~sed4lj1g z?-?Z6hUs3=iJ8HjJ>lvcs^&uM?KTIiJr}y{OSN~*kr{u=33w9|Zt z9W9#TNt%kKSQix?uByW2Y{i)8XdQf`gD=J91L5TOv(c- z{U~vE=!U3B;`HA6IYfQ9UGJgI+BT%;m86@|or$k1hx0QtFnn?PaC5R2-WhPSe!B55 zVV+Jt?l3S}>05#HJw}C%Ti)9)61-#M$<0=h>{a8*t?wiuD8_u09VAKl#(Y%uB+7}k zTV}I7A3bjb7c&kaiJ%r=ahHgEI^*wdYum?nmcim3_v~?8E8+Xg{Bdf$(k>rK_ZgE! zgrrDk#AHcld>6#(WR)PshND?!GXhtAC0M5ZT|SqYuvl;u(O^yD9JhHHqs2a62Wc2Erw9n zcPZ--@__mezX-~G{ie(O)JKXMrTsVzVr>0bJEM8Qt{45l3w_&$Icx!6V34V>E&(xnGrWwV}qOO5*~8m(6=P2Q}tK6bE$>j`~}?> zhxVOlu{};9>7%du=^b6Vc^GHy!(=a9mJ>T-)E1O?Q=h5-xk?U2>?o(5e!`mSk{p6R zD;vbI7*h)Nox`~c7}B|av?>Fr&M_KW<_+exgKcKLPN&R?6UJYeP8)UlP5)M7Gg|by zk`Aagt4qC3Ehv7?W=kq}#qccpQ#smF+Ze@6mHp6-Y|5@4Twpxsu#tX5uyXpXQtd49r@;Ni`O-DCTMMykfg>vkWst4x;S<4+6= zi+l0$@uiE3irzGSyGmd4{E;KL4k7gt+YF(5CJLv0`oguZE!UZ*KN~I2eIzpXo8KWr zeKy+Bw$p-CR||h@Q_Emhudn2c)bC2z!dxE%rpkx#twGkGb3A&ELDrRks11ySX~dy< z%MSA&qS<-^x(Oqie~NrMMVqJnj47^FHz{=a!@RF!?s=VuWN!v$y=>g@H!R$_YkFy^ z-Qy(C_C{Fl?b|*xmO@b|0xkESB(?u(ca++GIrmXoUqM%mS%O@;-Wpuj7_Z8>(sJn| z&ih#59yhj-x-MYc_lu2Oy67Gg)C98FyhjJ1xft4W7Kr@fSDo+Ls#`JzZa-%G&1O&6 z%IWk<9RA)obnA(j>-GtERCnL6#VTamUra<4n~;p|Sta^X0JE30y|Z7FA=uW6U!N2I z)A<4G{=inMUSOc|XslFQmmn%%-mjbw#%OB3Gsw0=jBiKZE$58TKV7JTGC0-^j3a{j z{CIAA(fL3;F|gq)1H`K#`h>jsyXgFy%3y*UP9sCTJYUTo60KLxvm+IsFHx8%#S-kV zuracqcv(!Js2YJ*d~TA*zYMJ{7fJ04fI#V}oE%#5_$w7yol#;&1~y7SG_T>$;PKC+ z3)55kq4!CQ330sfQZbZN7L#+>qpNlXKEgdEm82bUVs&n{@VsiGxYz*@m!hmfBExMy zbJ1@W>&*WAiMzaF(tB~eVKj@D z%gOC%+Ih>u2wu5oNxSyxu_{ALMRTd!cM4N=L-UjHsXM7b>29RNueJqosBgpR^iq4! zC1c=ppF+HobkY$K6*m3LQSxSqul`0-wy^u7&y;?S5zVB_UpSWE{qqcx=%dS5zhuM} zdnDg#ip=EEMLr^4`zkh7#>#(=2j?`Y`{%g9Kl8ZR*)-DXwWtpw&TWFawuyYz%He>D@lW{egdM+K@;`DeB6#tx^`%#In} ze3d^ zBH>tZG~qr)RBg+$%Ox!B7E6I4Rt**ENB$uv@JjlYM)Cb`&3?UDcdbe5`7+mDE8HSl z!T=FFjIPtMFbto;cAe|2Xkydj?Xm(E2#A2T(lyi62$0T>08-+>I_i_K83R`5w@bZL z?}As{iJAU=Co~EOM$Ik2eKh1boxCav^U4|%%o$2&(Yin{njOk((28hVdhbxtC}Z46 z)3$gtG3OGV|AzJ}$`sJTU&VtxSt1aUdM$rgE;=3{F}KszAfJeVjv>9iJB)XUFY##p z_i3~8<*yZuaUwH4Xz~%LVkht6X-B?26(aI_CVVPMqAq6_e&i>1IA}yqQ2{fMp)5Ga zp}kEb)jE*tZ!(p24xlVuHZq@HtKV9jytbolTl%njvTa$E+hEyGN{o!6LSrJL@9XQ! zSs-aSA?aVdMa~UjqGDpYR*XQPmHzi28`O*>?FsXY(2dPkF=Q+jqQV%B3zh~yar->m z2=ufi>e3HU-5Ss~C;ercOrG|8e4(b&CamSr)7f#;aj$e{{>y6)4jiLa%?LUZ(C? zy8Dv8Rn~?YX7&8I<4YSS*qUC3sEuDE{Y%KcO|5|Ag&7(o(CW=8Zbu_d=&wo`yLf4^ z{!v7njQ7ucgEk2*deCT^lMx`3@o2ckkDpj)OTxm#B`*wrhXHy?o&Jr<%6pL|RxuRj zO}@@6BO9souQH=HHh|CW^Lw9gOMkYd4I6EQc8Q@+6=S7w$MsrS0#h$S>M{<(f0^+1 zYja2JkKHf!cu@|^_F1qHg*POKd_*q~`Kr0H4bORRJeKOZQYUW_l)(A(5dY*VzU!ek z*y5mZ{EdvB9_jaoZ(d$6c`lZ{GHw`$j~3<4iOfZ>gu|M77;P`KMVO#4#W0OaedEsx zRvK^Y#`JGc6)C1=CloO0ELQN)kV|>nFCRQE{d<^%DfY`?L!x+2Y*A$8Fs+hQAWi#T z3_-fe@KMHswolH?F8~!K(6|b(s;}8PFp4+JV;ZKm~VdSrlzi{lPJ{c;cmz)I}<$aw94{H$EH8 z^{3bR;>&a6Oz^LzWM4zK zwzkFtOCP1-2F8bH6IIsb<>fFfEus!_@eKuTUa{!AF}3x84}EXM$*<2Q*GmUwyx-8$ zvcpazE<&7ISZz>h68uH9*9W4)o0O^7RhuJhsVkKUXvOF2)W_E9o(r+XtQXP{D;{P> zMo~@CWP~IXSdxp>ZS8H+JV~(aKR|m{`<7vo_2X>}Npo{qdATjEMdstpH;oe42JI(} zpzc#hj>%z-w2+DoHmsrfsUMv%;J3gs+g8-t)Aj`b{7^m-ZYV!DR9J@(D*BpFRPZ$?6e=b z|6c|!ZZ_}iKKwrnp#R69?l Si&MTR0TkubWNV}?;QtGnh_QA6 literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/natron.png b/cuegui/cuegui/images/apps/natron.png new file mode 100644 index 0000000000000000000000000000000000000000..6d8b56ba84e75e2e17cf2ae0ee51d9060769fa5b GIT binary patch literal 4285 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 Xhy4#Bgum*TNC0Rd^x)O1Pa^&U0-*A? literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/oiio.png b/cuegui/cuegui/images/apps/oiio.png new file mode 100644 index 0000000000000000000000000000000000000000..7396c24c440f52ee4d8d8c7ec0ec10c345e3cf71 GIT binary patch literal 2679 zcmV--3W)WIP)Px#AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBU!)=*4TMgRZ*>+9>8nVFWBmXwr~m6es3mzToA!u|dI{{H^S$;p_Q znB?T-;o;$AWMo@gTR=cSA0HnI3JM7c2^AF;H#av`RaIeOVWOg<{QUf_t*v5WVpLRA zG&D2_2nZk`AVWh#*Votk`}^PD-^Innw6wIVtE;N2s0000P85xX>jNstlj*gB20RcQbJhip8^YioN<>kuC%D1<-udlDYy}i`b z)ZE+LBK0ZEUV`Fx9 zb^`+g9UUEOYirKV&hql|FE1~Vk&)Ti*}lHMaBy%sIy(6H_-Scr)z#JK=jTE~LVbOG z%gf8n&CM(gwuUU0n+c3x|h?f`Wo6DJgn-dZD4Afq{W7EiE}Y zIra7RoSdAzyu2G58_&U1ZBqVWhacymFN=iymQBlXo$2d4RFfcHRii*t4%$=Q` zlarGU4i4Jd+C)S|Mn*>T^z`ZJ=}k>dAt515OiVjFJ5y6rNJvQL=H|%A$W~TXK|w(^ zH8pZ_a=^gAOG`_#va+M2qZk+%prD|Lh=^ZbUv6%0iHV7xo}Saw(-agGxw*MqTwGpW zUYnbn?d|P%cXwxJXDTWxGBPr3Y;3!`yN!*FF)=ZknwrGK#H6I85)u+wSy`~Kuz-Mo zrKP2Re}AT?rb$UjD=RBp{b;TL0004EOGiWihy@);00009a7bBm000XU000XU0RWnu z7ytkUYDq*vRA}DqSm{?3M-X37a7H-Ag@5H%V#@rVg(4$U2N{|D*nJ=kMrdA@wedwEkIc6zGo z=;^BZb@hrw0tqCLKmrLQkU#Og38QVoGo9nG-uiJSkGMKzhdPK z!<1qb7>s+>R6NfEc=-i}_rjv$5^3q`HDz;sSI2RBTME3<3jHQTY?PzLn>H&zTT!>N9-%Eo;g&vE;E%+oytY!Tu72#=c=X?bN~|rE8%ntF=vuP-LOG;|4c`+7EygCd;Oi zrZ?RY>IgE|4!~>Fc$q9)bx@9N1k>hXs;opDh-7*&C)A;Mgxn_wTI7*;975S|F-FF? zSqMpe6_#-dSb@zH=MCm$^I<$Vl4OMy>7Wx~%)Us-_+YDq6K};uDBZ5G?c`_{t(r z+)vwbLcIr=b4qKiE!hncow{;8or=erWb5x06|{j^mo|{!iNxs)JhK=M2st1f_{Ot1 ziBQQoE~HsWj8{?oP69(nM->T3720hVD=UmUbf>B+tmwmE!u;8PI8?4;IS}+OWWx~GM9jdb&FxRO3Nj7 zn#^6f#P1~GDm>2QacN&033K%j?F=G)xs^-oBjV(`9lw)Fr})3*+708E^wg8^)N+Xh z%_K32;*Uw~6?m+e&beJF#+cZB%%5FV$UfoH4g>IFy`WC$GN`x`&LsR4D(z0RjXDwEg;J6pgT8{Ii5E3sw)b@Guc{Xh67aULz-J0A{zxdE{{MP@_T5KwGiH|C5J1KaQY3w>9lY=47XWROdGzy z?p%C0Jy4lh*^r1dozA3#{F*p1L!M>=Yu^x+c~79FGTShTgA6w(r_KB}b>rZBoHw~w zIBgw;$B`1B#hZwdmybBD<%ntI0fElQ`(a=qw{XvR6T(6Qy{TiUZ!KlJ2hyPYi?uRIG>F+T;t+Ytn6VUL?U&r%UTU8mR@1m3Hyf?YDw7C57 zXNH~4n<eNB;ntXI$^ay=++k000?uMObu0 zZ*6U5Zgc=ca%Ew3Wn>_CX>@2HM@dakSAh-}0000bbVXQnWMOn=I%9HWVRU5xGB7bX zEif`IFgH{&Fgi3dIxsmaFfckWFc}W(ZvX%QC3HntbYx+4WjbwdWNBu305UK!G%YbP lEiyAyF*Q0dGdeOkD=;uRFfe7%pHKh*002ovPDHLkV1gR*-Jt*g literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/placeholder.png b/cuegui/cuegui/images/apps/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..dad292e86a3ecf208fab872b2e9e4a45d6f4776b GIT binary patch literal 695 zcmYL_TS!v@7{||yQa1%t$|!s|B9?U3QbSa7ZMBJae8|WM(a_AWyw@H?%pt8DM1^KS z=tEQr7D-Gu-TiDk+u6=;=j?WNchhYqG{_#bvnN0Jf8U?~!=I1eWJBG}bm~be008N1 z7Nd#0P(mq($UQT0_Zt9Ek{W87nc>)uKqOXZw85>I-R)ahlZ2wbRa(6cHLpn(zR(tI zFl|J(g<{$2x>Tvr`NI)+AcVU-h{oTQ6tu>f2u^jRQ!+JBOGg@H1QKC>GMvGLbhG4|$@muXK%t5Tl zlrC>Tq0w1vP9tVF;||>EHsKD3$FD}v;AV84ED3}#ryI54sMV%~5sTfW(Hl*e-RAPR zd_lq&G~1lsVA$dIX;HISp)!)+`8Q-LSPE${Cjn{oaR_%_`@s5&q6>ol`7VU5G=Sc#s9VYG&W^<{K=<)0;0%L z@8{dBb2(=Rt9bIcpN|^bb~zoZUFEG4WeWp$kk)WXWvpT@n>xAjHGP2Y-4n2<`L|jq zUotbhyIZEOE8e~$!dYDS$cuPfz6$2=Sw~c2XP~Izb|-2SU$T`+_A&KwtJEe b0FbWfA7zP4gQ%TqLn3E0>llmldt?6r`w4(b literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/postprocess.png b/cuegui/cuegui/images/apps/postprocess.png new file mode 100644 index 0000000000000000000000000000000000000000..74978c2f60e98a7d169d2fd74271728e805cd8a2 GIT binary patch literal 65812 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 PGX~kQdwbP3|3Cf*(77D) literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/rm.png b/cuegui/cuegui/images/apps/rm.png new file mode 100644 index 0000000000000000000000000000000000000000..689f725fd3647a9588a11cebc774d74483817ad2 GIT binary patch literal 1499 zcmeAS@N?(olHy`uVBq!ia0vp^DIm45bDP46hOx7_4S6Fo+k-*%fF5)E5)r z6XFU~U_1(F5CZ@I|7YTSQwI#+qLLuLUP~hGQU^fKfeEv z`D?lB$sC23^SwTECH>f&`+E{o;h%op?2fMXzo#RfOFv9xlaVUAea^yMZP)q@xAyNj zah`|wnH8U)knmGOqZ#vTrUZTq`0_3|Rl8+^Nuz0rPt;Nt(NlLDdY<1qeHdsvW0JSK zOR*!T?KvQav%n*=n1NAX0thp9uDP^>fr070r;B4q#=W<*(!(D)h_t0|SfG#*(ACB2 z7`U-r3$)`51vTKdE~v~WX6T2rimxdOrLnu#sA=novr$- zr=?|aJ=59Kq$9ITcgfW75baXU+@mpJv+urRxcSyB`(nmo-6gRnu3UU+QnQWc+&R93 z*~@j7dV5?s>#4gg`opdlUN2v^+N{&hwie%Y;LY2GO<#5@_Pd|^+VJw-;-)YB6HMZE zZtj~neFyX3kO>DBC#6N)lT&+lVZp4OEf(`VGE$~2k#x3`Txj^Hug@cA-JQFYHHYMX ztntjBuw91e&Of}) ze9?33?(P3~ulT$7u0FGk_3aHub44Gu`2YL%lzT!V`$;de*H0KNtsZDw_c;Em;t7l^ow0Efm6u=aKBI;|=4WGM z#q|GQdNyeJ?)FK#IZ0z|cb1 zz)07?JjBqz%EZ{pz*5`5z{5>! VYDg@t<_BtE@O1TaS?83{1OW7HHy{82 literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/shell.png b/cuegui/cuegui/images/apps/shell.png new file mode 100644 index 0000000000000000000000000000000000000000..f9a53c4ce153d33fe902c54794ad559c3af021bc GIT binary patch literal 5422 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 literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/images/apps/terminal.png b/cuegui/cuegui/images/apps/terminal.png new file mode 100644 index 0000000000000000000000000000000000000000..f9a53c4ce153d33fe902c54794ad559c3af021bc GIT binary patch literal 5422 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 literal 0 HcmV?d00001 diff --git a/cuegui/cuegui/nodegraph/__init__.py b/cuegui/cuegui/nodegraph/__init__.py new file mode 100644 index 000000000..b36cfbc5f --- /dev/null +++ b/cuegui/cuegui/nodegraph/__init__.py @@ -0,0 +1,21 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""nodegraph is an OpenCue specific extension of NodeGraphQt + +The docs for NodeGraphQt can be found at: +http://chantasticvfx.com/nodeGraphQt/html/nodes.html +""" +from .nodes import CueLayerNode diff --git a/cuegui/cuegui/nodegraph/nodes/__init__.py b/cuegui/cuegui/nodegraph/nodes/__init__.py new file mode 100644 index 000000000..a31d03947 --- /dev/null +++ b/cuegui/cuegui/nodegraph/nodes/__init__.py @@ -0,0 +1,19 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Module housing node implementations that work with NodeGraphQt""" + + +from .layer import CueLayerNode \ No newline at end of file diff --git a/cuegui/cuegui/nodegraph/nodes/base.py b/cuegui/cuegui/nodegraph/nodes/base.py new file mode 100644 index 000000000..b9987c775 --- /dev/null +++ b/cuegui/cuegui/nodegraph/nodes/base.py @@ -0,0 +1,80 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Base class for any cue nodes to work with NodeGraphQt""" + + +from builtins import str +from NodeGraphQt import BaseNode +from cuegui.nodegraph.widgets.nodeWidgets import NodeProgressBar + + + +class CueBaseNode(BaseNode): + """Base class for any cue nodes to work with NodeGraphQt""" + + __identifier__ = "aswf.opencue" + + NODE_NAME = "Base" + + def __init__(self, rpcObject=None): + super(CueBaseNode, self).__init__() + self.add_input(name="parent", multi_input=True, display_name=False) + self.add_output(name="children", multi_output=True, display_name=False) + + self.rpcObject = rpcObject + + def setRpcObject(self, rpcObject): + """Set the nodes rpc object + @param rpc object to set on node + @type opencue.wrappers.layer.Layer + """ + self.rpcObject = rpcObject + + def addProgressBar( + self, + name="", + label="", + value=0, + max_value=100, + display_format="%p%", + tab=None + ): + """Add progress bar property to node + @param name: name of the custom property + @type name: str + @param label: label to be displayed + @type label: str + @param value: value to set progress bar to + @type value: int + @param max_value: max_value value progress bar can go up to + @type max_value: int + @param display_format: string format to display value on progress bar with + @type display_format: str + @param tab:name of the widget tab to display in. + @type tab: str + """ + self.create_property( + name, str(value), tab=tab + ) + widget = NodeProgressBar( + self.view,name, + label, + value, + max_value=max_value, + display_format=display_format + ) + widget.value_changed.connect(self.set_property) + self.view.add_widget(widget) diff --git a/cuegui/cuegui/nodegraph/nodes/layer.py b/cuegui/cuegui/nodegraph/nodes/layer.py new file mode 100644 index 000000000..eff9fc3f9 --- /dev/null +++ b/cuegui/cuegui/nodegraph/nodes/layer.py @@ -0,0 +1,95 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Implementation of a Cue Layer node that works with NodeGraphQt""" + + +from __future__ import division +import os +from qtpy import QtGui +import NodeGraphQt.qgraphics.node_base +import opencue +import cuegui.images +from cuegui.Constants import RGB_FRAME_STATE +from cuegui.nodegraph.nodes.base import CueBaseNode + + +class CueLayerNode(CueBaseNode): + """Implementation of a Cue Layer node that works with NodeGraphQt""" + + __identifier__ = "aswf.opencue" + + NODE_NAME = "Layer" + + def __init__(self, layerRpcObject=None): + super(CueLayerNode, self).__init__(rpcObject=layerRpcObject) + + self.set_name(layerRpcObject.name()) + + NodeGraphQt.qgraphics.node_base.NODE_ICON_SIZE = 30 + services = layerRpcObject.services() + if services: + app = services[0].name() + imagesPath = cuegui.images.__path__[0] + iconPath = os.path.join(imagesPath, "apps", app + ".png") + if os.path.exists(iconPath): + self.set_icon(iconPath) + + self.addProgressBar( + name="succeededFrames", + label="", + value=layerRpcObject.succeededFrames(), + max_value=layerRpcObject.totalFrames(), + display_format="%v / %m" + ) + + font = self.view.text_item.font() + font.setPointSize(16) + self.view.text_item.setFont(font) + + self.setRpcObject(layerRpcObject) + + def updateNodeColour(self): + """Update the colour of the node to reflect the status of the layer""" + # default colour + r, g, b = self.color() + color = QtGui.QColor(r, g, b) + + # state specific colours + if self.rpcObject.totalFrames() == self.rpcObject.succeededFrames(): + color = RGB_FRAME_STATE[opencue.api.job_pb2.SUCCEEDED] + if self.rpcObject.waitingFrames() > 0: + color = RGB_FRAME_STATE[opencue.api.job_pb2.WAITING] + if self.rpcObject.dependFrames() > 0: + color = RGB_FRAME_STATE[opencue.api.job_pb2.DEPEND] + if self.rpcObject.runningFrames() > 0: + color = RGB_FRAME_STATE[opencue.api.job_pb2.RUNNING] + if self.rpcObject.deadFrames() > 0: + color = RGB_FRAME_STATE[opencue.api.job_pb2.DEAD] + + self.set_color( + color.red() // 2, + color.green() // 2, + color.blue() // 2 + ) + + def setRpcObject(self, rpcObject): + """Set the nodes layer rpc object + @param rpc object to set on node + @type opencue.wrappers.layer.Layer + """ + super(CueLayerNode, self).setRpcObject(rpcObject) + self.set_property("succeededFrames", rpcObject.succeededFrames()) + self.updateNodeColour() diff --git a/cuegui/cuegui/nodegraph/widgets/__init__.py b/cuegui/cuegui/nodegraph/widgets/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/cuegui/cuegui/nodegraph/widgets/__init__.py @@ -0,0 +1 @@ + diff --git a/cuegui/cuegui/nodegraph/widgets/nodeWidgets.py b/cuegui/cuegui/nodegraph/widgets/nodeWidgets.py new file mode 100644 index 000000000..69847bcfa --- /dev/null +++ b/cuegui/cuegui/nodegraph/widgets/nodeWidgets.py @@ -0,0 +1,112 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Module defining custom widgets that appear on nodes in the nodegraph. + +The classes defined here inherit from NodeGraphQt base classes, therefore any +snake_case methods defined here are overriding the base class and must remain +snake_case to work properly. +""" + + +from qtpy import QtWidgets +from qtpy import QtCore +from NodeGraphQt.widgets.node_widgets import NodeBaseWidget + + +class NodeProgressBar(NodeBaseWidget): + """ + ProgressBar Node Widget. + """ + + def __init__( + self, + parent=None, + name="", + label="", + value=0, + max_value=100, + display_format="%p%" + ): + super(NodeProgressBar, self).__init__(parent, name, label) + self._progressbar = QtWidgets.QProgressBar() + self._progressbar.setAlignment(QtCore.Qt.AlignCenter) + self._progressbar.setFormat(display_format) + self._progressbar.setMaximum(max_value) + self._progressbar.setValue(value) + progress_style = """ +QProgressBar { + background-color: rgba(40, 40, 40, 255); + border: 1px solid grey; + border-radius: 1px; + margin: 0px; +} +QProgressBar::chunk { + background-color: rgba(100, 120, 250, 150); +} + """ + self._progressbar.setStyleSheet(progress_style) + self.set_custom_widget(self._progressbar) + self.text = str(value) + + @property + def type_(self): + """ + @return: Name of widget type + @rtype: str + """ + return "ProgressBarNodeWidget" + + def get_value(self): + """Get value from progress bar on node + @return: progress bar value + @rtype: int + """ + return self._progressbar.value() + + def set_value(self, text=0): + """Set value on progress bar + @param text: Text value to set on progress bar + @type text: int + """ + if int(float(text)) != self.get_value(): + self._progressbar.setValue(int(float(text))) + self.on_value_changed() + + @property + def value(self): + """Get value from progress bar on node + XXX: This property shouldn't be required as it's been superseded by get_value, + however the progress bar doesn't update without it. Believe it may be + a bug in NodeGraphQt's `NodeObject.set_property`. We should remove this + once it's been resolved. + @return: progress bar value + @rtype: int + """ + return self._progressbar.value() + + @value.setter + def value(self, value=0): + """Set value on progress bar + XXX: This property shouldn't be required as it's been superseded by set_value, + however the progress bar doesn't update without it. Believe it may be + a bug in NodeGraphQt's `NodeObject.set_property`. We should remove this + once it's been resolved. + @param value: Value to set on progress bar + @type value: int + """ + if int(float(value)) != self.value: + self._progressbar.setValue(int(float(value))) + self.on_value_changed() diff --git a/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py b/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py new file mode 100644 index 000000000..d4c0c1628 --- /dev/null +++ b/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py @@ -0,0 +1,102 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + + +"""Plugin for displaying node graph representation of layer in the selected job. + +Job selection is triggered by other plugins using the application's view_object signal.""" + + +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import + +from qtpy import QtGui + +import opencue + +import cuegui.AbstractDockWidget +import cuegui.Logger +import cuegui.Utils + +import cuegui.JobMonitorGraph + + +logger = cuegui.Logger.getLogger(__file__) + +PLUGIN_NAME = "Job Graph" +PLUGIN_CATEGORY = "Cuetopia" +PLUGIN_DESCRIPTION = "Visualise a job's layers in a node graph" +PLUGIN_PROVIDES = "MonitorGraphDockWidget" + + +class MonitorGraphDockWidget(cuegui.AbstractDockWidget.AbstractDockWidget): + """Plugin for displaying node graph representation of layer in the selected job.""" + + def __init__(self, parent): + """Creates the dock widget and docks it to the parent. + @param parent: The main window to dock to + @type parent: QMainWindow""" + cuegui.AbstractDockWidget.AbstractDockWidget.__init__(self, parent, PLUGIN_NAME) + + self.__job = None + + self.__monitorGraph = cuegui.JobMonitorGraph.JobMonitorGraph(self) + + self.setAcceptDrops(True) + + self.layout().addWidget(self.__monitorGraph) + + cuegui.app().view_object.connect(self.__setJob) + cuegui.app().unmonitor.connect(self.__unmonitor) + cuegui.app().facility_changed.connect(self.__setJob) + + def dragEnterEvent(self, event): + cuegui.Utils.dragEnterEvent(event) + + def dragMoveEvent(self, event): + cuegui.Utils.dragMoveEvent(event) + + def dropEvent(self, event): + for jobName in cuegui.Utils.dropEvent(event): + self.__setJob(jobName) + + def __setJob(self, job = None): + """Set the job to be displayed + @param job: Selected job + @type job: opencue.wrappers.job.Job + """ + if cuegui.Utils.isJob(job) and self.__job and opencue.id(job) == opencue.id(self.__job): + return + + newJob = cuegui.Utils.findJob(job) + if newJob: + self.__job = newJob + self.setWindowTitle("%s" % newJob.name()) + self.raise_() + + self.__monitorGraph.setJob(newJob) + elif not job and self.__job: + self.__unmonitor(self.__job) + + def __unmonitor(self, proxy): + """Unmonitors the current job if it matches the supplied proxy. + @param proxy: A job proxy + @type proxy: proxy""" + if self.__job and self.__job == proxy: + self.__job = None + self.setWindowTitle("Monitor Job Graph") + + self.__monitorGraph.setJob(None) diff --git a/pycue/opencue/wrappers/layer.py b/pycue/opencue/wrappers/layer.py index 7a9782e0e..1820f5230 100644 --- a/pycue/opencue/wrappers/layer.py +++ b/pycue/opencue/wrappers/layer.py @@ -587,3 +587,10 @@ def parent(self): :return: the layer's parent job """ return opencue.api.getJob(self.data.parent_id) + + def services(self): + """Returns list of services applied to this layer + :rtype: opencue.wrappers.service.Service + :return: the layer's services + """ + return [opencue.api.getService(service) for service in self.data.services] diff --git a/requirements_gui.txt b/requirements_gui.txt index 76129b277..1f5b19637 100644 --- a/requirements_gui.txt +++ b/requirements_gui.txt @@ -2,4 +2,4 @@ PySide6==6.7.1;python_version>"3.11" PySide6==6.5.3;python_version=="3.11" PySide2==5.15.2.1;python_version<="3.10" QtPy==1.11.3;python_version<"3.7" -QtPy==2.4.1;python_version>="3.7" \ No newline at end of file +QtPy==2.4.1;python_version>="3.7" From a558840e508addea6969de2dafec80e5c35508ed Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sat, 29 Jun 2024 21:57:01 +0200 Subject: [PATCH 05/26] Fix pylint --- cuegui/cuegui/AbstractGraphWidget.py | 2 +- cuegui/cuegui/JobMonitorGraph.py | 1 - cuegui/cuegui/LayerMonitorTree.py | 2 +- cuegui/cuegui/nodegraph/nodes/__init__.py | 2 +- cuegui/cuegui/nodegraph/nodes/base.py | 1 - cuegui/cuegui/nodegraph/widgets/__init__.py | 1 - cuegui/cuegui/plugins/MonitorJobGraphPlugin.py | 2 -- 7 files changed, 3 insertions(+), 8 deletions(-) diff --git a/cuegui/cuegui/AbstractGraphWidget.py b/cuegui/cuegui/AbstractGraphWidget.py index cc63f6580..1cd1ecba0 100644 --- a/cuegui/cuegui/AbstractGraphWidget.py +++ b/cuegui/cuegui/AbstractGraphWidget.py @@ -16,7 +16,6 @@ """Base class for CueGUI graph widgets.""" from qtpy import QtCore -from qtpy import QtGui from qtpy import QtWidgets from NodeGraphQt import NodeGraph @@ -33,6 +32,7 @@ def __init__(self, parent=None): self.setupUI() self.timer = QtCore.QTimer(self) + # pylint: disable=no-member self.timer.timeout.connect(self.update) self.timer.setInterval(1000 * 20) diff --git a/cuegui/cuegui/JobMonitorGraph.py b/cuegui/cuegui/JobMonitorGraph.py index 66b1b72c5..e761a50b0 100644 --- a/cuegui/cuegui/JobMonitorGraph.py +++ b/cuegui/cuegui/JobMonitorGraph.py @@ -16,7 +16,6 @@ """Node graph to display Layers of a Job""" -from qtpy import QtGui from qtpy import QtWidgets import cuegui.Utils diff --git a/cuegui/cuegui/LayerMonitorTree.py b/cuegui/cuegui/LayerMonitorTree.py index 2c91b23fe..4d6089d88 100644 --- a/cuegui/cuegui/LayerMonitorTree.py +++ b/cuegui/cuegui/LayerMonitorTree.py @@ -22,7 +22,6 @@ from qtpy import QtCore from qtpy import QtWidgets -from qtpy import QtGui from opencue.exception import EntityNotFoundException @@ -144,6 +143,7 @@ def __init__(self, parent): tip="Timeout for a frames\' LLU, Hours:Minutes") cuegui.AbstractTreeWidget.AbstractTreeWidget.__init__(self, parent) + # pylint: disable=no-member self.itemSelectionChanged.connect(self.__itemSelectionChangedFilterLayer) cuegui.app().select_layers.connect(self.__handle_select_layers) diff --git a/cuegui/cuegui/nodegraph/nodes/__init__.py b/cuegui/cuegui/nodegraph/nodes/__init__.py index a31d03947..678c71e00 100644 --- a/cuegui/cuegui/nodegraph/nodes/__init__.py +++ b/cuegui/cuegui/nodegraph/nodes/__init__.py @@ -16,4 +16,4 @@ """Module housing node implementations that work with NodeGraphQt""" -from .layer import CueLayerNode \ No newline at end of file +from .layer import CueLayerNode diff --git a/cuegui/cuegui/nodegraph/nodes/base.py b/cuegui/cuegui/nodegraph/nodes/base.py index b9987c775..fe2596301 100644 --- a/cuegui/cuegui/nodegraph/nodes/base.py +++ b/cuegui/cuegui/nodegraph/nodes/base.py @@ -21,7 +21,6 @@ from cuegui.nodegraph.widgets.nodeWidgets import NodeProgressBar - class CueBaseNode(BaseNode): """Base class for any cue nodes to work with NodeGraphQt""" diff --git a/cuegui/cuegui/nodegraph/widgets/__init__.py b/cuegui/cuegui/nodegraph/widgets/__init__.py index 8b1378917..e69de29bb 100644 --- a/cuegui/cuegui/nodegraph/widgets/__init__.py +++ b/cuegui/cuegui/nodegraph/widgets/__init__.py @@ -1 +0,0 @@ - diff --git a/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py b/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py index d4c0c1628..8b10e9b84 100644 --- a/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py +++ b/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py @@ -23,8 +23,6 @@ from __future__ import division from __future__ import absolute_import -from qtpy import QtGui - import opencue import cuegui.AbstractDockWidget From cb22c46e3c45b5d9d0187dccb1d044401709a7e6 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sat, 29 Jun 2024 23:13:44 +0200 Subject: [PATCH 06/26] Lock nodes names to be non-editable --- cuegui/cuegui/nodegraph/nodes/layer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cuegui/cuegui/nodegraph/nodes/layer.py b/cuegui/cuegui/nodegraph/nodes/layer.py index eff9fc3f9..90bfc0817 100644 --- a/cuegui/cuegui/nodegraph/nodes/layer.py +++ b/cuegui/cuegui/nodegraph/nodes/layer.py @@ -58,6 +58,8 @@ def __init__(self, layerRpcObject=None): font = self.view.text_item.font() font.setPointSize(16) self.view.text_item.setFont(font) + # Lock the node text so it can't be edited + self.view.text_item.set_locked(True) self.setRpcObject(layerRpcObject) From 9918fc59920f99db9b38e2351d6f5e87b2067a4a Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sat, 29 Jun 2024 23:14:16 +0200 Subject: [PATCH 07/26] Fix some small linting warnings --- cuegui/cuegui/AbstractGraphWidget.py | 2 +- cuegui/cuegui/LayerMonitorTree.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cuegui/cuegui/AbstractGraphWidget.py b/cuegui/cuegui/AbstractGraphWidget.py index 1cd1ecba0..74c8a66c4 100644 --- a/cuegui/cuegui/AbstractGraphWidget.py +++ b/cuegui/cuegui/AbstractGraphWidget.py @@ -29,6 +29,7 @@ class AbstractGraphWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(AbstractGraphWidget, self).__init__(parent=parent) + self.graph = NodeGraph() self.setupUI() self.timer = QtCore.QTimer(self) @@ -41,7 +42,6 @@ def __init__(self, parent=None): def setupUI(self): """Setup the UI.""" - self.graph = NodeGraph() try: self.graph.register_node(CueLayerNode) except NodeRegistrationError: diff --git a/cuegui/cuegui/LayerMonitorTree.py b/cuegui/cuegui/LayerMonitorTree.py index 4d6089d88..ad323c758 100644 --- a/cuegui/cuegui/LayerMonitorTree.py +++ b/cuegui/cuegui/LayerMonitorTree.py @@ -266,11 +266,11 @@ def __itemSelectionChangedFilterLayer(self): cuegui.app().select_layers.emit(layers) def __handle_select_layers(self, layerRpcObjects): - '''Select incoming Layers in tree. + """Select incoming Layers in tree. Slot connected to QtGui.qApp.select_layers inorder to handle selecting Layers in Tree. Also emits signal to filter FrameMonitor - ''' + """ received_layers = [l.data.name for l in layerRpcObjects] current_layers = [l.data.name for l in self.selectedObjects()] if received_layers == current_layers: From 22634fff3ccf621f49856bda1eba540e5ac8f100 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 00:04:32 +0200 Subject: [PATCH 08/26] Import fork of NodeGraphQt and remove reference to external repo --- cuegui/NodeGraphQt/__init__.py | 93 + cuegui/NodeGraphQt/base/__init__.py | 0 cuegui/NodeGraphQt/base/commands.py | 500 +++ cuegui/NodeGraphQt/base/factory.py | 102 + cuegui/NodeGraphQt/base/graph.py | 3050 +++++++++++++++++ cuegui/NodeGraphQt/base/menu.py | 335 ++ cuegui/NodeGraphQt/base/model.py | 627 ++++ cuegui/NodeGraphQt/base/node.py | 529 +++ cuegui/NodeGraphQt/base/port.py | 495 +++ cuegui/NodeGraphQt/constants.py | 254 ++ cuegui/NodeGraphQt/custom_widgets/__init__.py | 0 .../custom_widgets/nodes_palette.py | 346 ++ .../NodeGraphQt/custom_widgets/nodes_tree.py | 141 + .../custom_widgets/properties_bin/__init__.py | 0 .../custom_widget_color_picker.py | 119 + .../custom_widget_file_paths.py | 76 + .../properties_bin/custom_widget_slider.py | 132 + .../custom_widget_value_edit.py | 303 ++ .../properties_bin/custom_widget_vectors.py | 138 + .../properties_bin/node_property_factory.py | 60 + .../properties_bin/node_property_widgets.py | 873 +++++ .../properties_bin/prop_widgets_abstract.py | 49 + .../properties_bin/prop_widgets_base.py | 305 ++ cuegui/NodeGraphQt/errors.py | 26 + cuegui/NodeGraphQt/nodes/__init__.py | 0 cuegui/NodeGraphQt/nodes/backdrop_node.py | 141 + cuegui/NodeGraphQt/nodes/base_node.py | 872 +++++ cuegui/NodeGraphQt/nodes/base_node_circle.py | 46 + cuegui/NodeGraphQt/nodes/group_node.py | 176 + cuegui/NodeGraphQt/nodes/port_node.py | 135 + cuegui/NodeGraphQt/pkg_info.py | 10 + cuegui/NodeGraphQt/qgraphics/__init__.py | 0 cuegui/NodeGraphQt/qgraphics/node_abstract.py | 261 ++ cuegui/NodeGraphQt/qgraphics/node_backdrop.py | 311 ++ cuegui/NodeGraphQt/qgraphics/node_base.py | 1056 ++++++ cuegui/NodeGraphQt/qgraphics/node_circle.py | 532 +++ cuegui/NodeGraphQt/qgraphics/node_group.py | 317 ++ .../qgraphics/node_overlay_disabled.py | 108 + cuegui/NodeGraphQt/qgraphics/node_port_in.py | 234 ++ cuegui/NodeGraphQt/qgraphics/node_port_out.py | 234 ++ .../NodeGraphQt/qgraphics/node_text_item.py | 117 + cuegui/NodeGraphQt/qgraphics/pipe.py | 666 ++++ cuegui/NodeGraphQt/qgraphics/port.py | 325 ++ cuegui/NodeGraphQt/qgraphics/slicer.py | 87 + cuegui/NodeGraphQt/widgets/__init__.py | 0 cuegui/NodeGraphQt/widgets/actions.py | 112 + cuegui/NodeGraphQt/widgets/dialogs.py | 92 + .../NodeGraphQt/widgets/icons/node_base.png | Bin 0 -> 17542 bytes cuegui/NodeGraphQt/widgets/node_graph.py | 125 + cuegui/NodeGraphQt/widgets/node_widgets.py | 448 +++ cuegui/NodeGraphQt/widgets/scene.py | 171 + cuegui/NodeGraphQt/widgets/tab_search.py | 311 ++ cuegui/NodeGraphQt/widgets/viewer.py | 1653 +++++++++ cuegui/NodeGraphQt/widgets/viewer_nav.py | 198 ++ 54 files changed, 17291 insertions(+) create mode 100644 cuegui/NodeGraphQt/__init__.py create mode 100644 cuegui/NodeGraphQt/base/__init__.py create mode 100644 cuegui/NodeGraphQt/base/commands.py create mode 100644 cuegui/NodeGraphQt/base/factory.py create mode 100644 cuegui/NodeGraphQt/base/graph.py create mode 100644 cuegui/NodeGraphQt/base/menu.py create mode 100644 cuegui/NodeGraphQt/base/model.py create mode 100644 cuegui/NodeGraphQt/base/node.py create mode 100644 cuegui/NodeGraphQt/base/port.py create mode 100644 cuegui/NodeGraphQt/constants.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/__init__.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/nodes_palette.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/nodes_tree.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/__init__.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py create mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py create mode 100644 cuegui/NodeGraphQt/errors.py create mode 100644 cuegui/NodeGraphQt/nodes/__init__.py create mode 100644 cuegui/NodeGraphQt/nodes/backdrop_node.py create mode 100644 cuegui/NodeGraphQt/nodes/base_node.py create mode 100644 cuegui/NodeGraphQt/nodes/base_node_circle.py create mode 100644 cuegui/NodeGraphQt/nodes/group_node.py create mode 100644 cuegui/NodeGraphQt/nodes/port_node.py create mode 100644 cuegui/NodeGraphQt/pkg_info.py create mode 100644 cuegui/NodeGraphQt/qgraphics/__init__.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_abstract.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_backdrop.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_base.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_circle.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_group.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_port_in.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_port_out.py create mode 100644 cuegui/NodeGraphQt/qgraphics/node_text_item.py create mode 100644 cuegui/NodeGraphQt/qgraphics/pipe.py create mode 100644 cuegui/NodeGraphQt/qgraphics/port.py create mode 100644 cuegui/NodeGraphQt/qgraphics/slicer.py create mode 100644 cuegui/NodeGraphQt/widgets/__init__.py create mode 100644 cuegui/NodeGraphQt/widgets/actions.py create mode 100644 cuegui/NodeGraphQt/widgets/dialogs.py create mode 100644 cuegui/NodeGraphQt/widgets/icons/node_base.png create mode 100644 cuegui/NodeGraphQt/widgets/node_graph.py create mode 100644 cuegui/NodeGraphQt/widgets/node_widgets.py create mode 100644 cuegui/NodeGraphQt/widgets/scene.py create mode 100644 cuegui/NodeGraphQt/widgets/tab_search.py create mode 100644 cuegui/NodeGraphQt/widgets/viewer.py create mode 100644 cuegui/NodeGraphQt/widgets/viewer_nav.py diff --git a/cuegui/NodeGraphQt/__init__.py b/cuegui/NodeGraphQt/__init__.py new file mode 100644 index 000000000..5ac835fc2 --- /dev/null +++ b/cuegui/NodeGraphQt/__init__.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +**NodeGraphQt** is a node graph framework that can be implemented and re purposed +into applications that supports **PySide2**. + +project: https://github.com/jchanvfx/NodeGraphQt +documentation: https://jchanvfx.github.io/NodeGraphQt/api/html/index.html + +example code: + +.. code-block:: python + :linenos: + + from NodeGraphQt import QtWidgets, NodeGraph, BaseNode + + + class MyNode(BaseNode): + + __identifier__ = 'io.github.jchanvfx' + NODE_NAME = 'My Node' + + def __init__(self): + super(MyNode, self).__init__() + self.add_input('foo', color=(180, 80, 0)) + self.add_output('bar') + + if __name__ == '__main__': + app = QtWidgets.QApplication([]) + graph = NodeGraph() + + graph.register_node(BaseNode) + graph.register_node(BackdropNode) + + backdrop = graph.create_node('nodeGraphQt.nodes.Backdrop', name='Backdrop') + node_a = graph.create_node('io.github.jchanvfx.MyNode', name='Node A') + node_b = graph.create_node('io.github.jchanvfx.MyNode', name='Node B', color='#5b162f') + + node_a.set_input(0, node_b.output(0)) + + viewer = graph.viewer() + viewer.show() + + app.exec_() +""" +from .pkg_info import __version__ as VERSION +from .pkg_info import __license__ as LICENSE + +# node graph +from .base.graph import NodeGraph, SubGraph +from .base.menu import NodesMenu, NodeGraphMenu, NodeGraphCommand + +# nodes & ports +from .base.port import Port +from .base.node import NodeObject +from .nodes.base_node import BaseNode +from .nodes.base_node_circle import BaseNodeCircle +from .nodes.backdrop_node import BackdropNode +from .nodes.group_node import GroupNode + +# widgets +from .widgets.node_widgets import NodeBaseWidget +from .custom_widgets.nodes_tree import NodesTreeWidget +from .custom_widgets.nodes_palette import NodesPaletteWidget +from .custom_widgets.properties_bin.node_property_widgets import ( + NodePropEditorWidget, + PropertiesBinWidget +) + + +__version__ = VERSION +__all__ = [ + 'BackdropNode', + 'BaseNode', + 'BaseNodeCircle', + 'GroupNode', + 'LICENSE', + 'NodeBaseWidget', + 'NodeGraph', + 'NodeGraphCommand', + 'NodeGraphMenu', + 'NodeObject', + 'NodesPaletteWidget', + 'NodePropEditorWidget', + 'NodesTreeWidget', + 'NodesMenu', + 'Port', + 'PropertiesBinWidget', + 'SubGraph', + 'VERSION', + 'constants', + 'custom_widgets' +] diff --git a/cuegui/NodeGraphQt/base/__init__.py b/cuegui/NodeGraphQt/base/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cuegui/NodeGraphQt/base/commands.py b/cuegui/NodeGraphQt/base/commands.py new file mode 100644 index 000000000..0dbd16cdb --- /dev/null +++ b/cuegui/NodeGraphQt/base/commands.py @@ -0,0 +1,500 @@ +#!/usr/bin/python +from qtpy import QtWidgets + +from NodeGraphQt.constants import PortTypeEnum + + +class PropertyChangedCmd(QtWidgets.QUndoCommand): + """ + Node property changed command. + + Args: + node (NodeGraphQt.NodeObject): node. + name (str): node property name. + value (object): node property value. + """ + + def __init__(self, node, name, value): + QtWidgets.QUndoCommand.__init__(self) + self.setText('property "{}:{}"'.format(node.name(), name)) + self.node = node + self.name = name + self.old_val = node.get_property(name) + self.new_val = value + + def set_node_property(self, name, value): + """ + updates the node view and model. + """ + # set model data. + model = self.node.model + model.set_property(name, value) + + # set view data. + view = self.node.view + + # view widgets. + if hasattr(view, 'widgets') and name in view.widgets.keys(): + # check if previous value is identical to current value, + # prevent signals from causing an infinite loop. + if view.widgets[name].get_value() != value: + view.widgets[name].set_value(value) + + # view properties. + if name in view.properties.keys(): + # remap "pos" to "xy_pos" node view has pre-existing pos method. + if name == 'pos': + name = 'xy_pos' + setattr(view, name, value) + + # emit property changed signal. + graph = self.node.graph + graph.property_changed.emit(self.node, self.name, value) + + def undo(self): + if self.old_val != self.new_val: + self.set_node_property(self.name, self.old_val) + + def redo(self): + if self.old_val != self.new_val: + self.set_node_property(self.name, self.new_val) + + +class NodeVisibleCmd(QtWidgets.QUndoCommand): + """ + Node visibility changed command. + + Args: + node (NodeGraphQt.NodeObject): node. + visible (bool): node visible value. + """ + + def __init__(self, node, visible): + QtWidgets.QUndoCommand.__init__(self) + self.node = node + self.visible = visible + self.selected = self.node.selected() + + def set_node_visible(self, visible): + model = self.node.model + model.set_property('visible', visible) + + node_view = self.node.view + node_view.visible = visible + + # redraw the connected pipes in the scene. + ports = node_view.inputs + node_view.outputs + for port in ports: + for pipe in port.connected_pipes: + pipe.update() + + # restore the node selected state. + if self.selected != node_view.isSelected(): + node_view.setSelected(model.selected) + + # emit property changed signal. + graph = self.node.graph + graph.property_changed.emit(self.node, 'visible', visible) + + def undo(self): + self.set_node_visible(not self.visible) + + def redo(self): + self.set_node_visible(self.visible) + + +class NodeWidgetVisibleCmd(QtWidgets.QUndoCommand): + """ + Node widget visibility command. + + Args: + node (NodeGraphQt.NodeObject): node object. + name (str): node widget name. + visible (bool): initial visibility state. + """ + + def __init__(self, node, name, visible): + QtWidgets.QUndoCommand.__init__(self) + label = 'show' if visible else 'hide' + self.setText('{} node widget "{}"'.format(label, name)) + self.view = node.view + self.node_widget = self.view.get_widget(name) + self.visible = visible + + def undo(self): + self.node_widget.setVisible(not self.visible) + self.view.draw_node() + + def redo(self): + self.node_widget.setVisible(self.visible) + self.view.draw_node() + + +class NodeMovedCmd(QtWidgets.QUndoCommand): + """ + Node moved command. + + Args: + node (NodeGraphQt.NodeObject): node. + pos (tuple(float, float)): new node position. + prev_pos (tuple(float, float)): previous node position. + """ + + def __init__(self, node, pos, prev_pos): + QtWidgets.QUndoCommand.__init__(self) + self.node = node + self.pos = pos + self.prev_pos = prev_pos + + def undo(self): + self.node.view.xy_pos = self.prev_pos + self.node.model.pos = self.prev_pos + + def redo(self): + if self.pos == self.prev_pos: + return + self.node.view.xy_pos = self.pos + self.node.model.pos = self.pos + + +class NodeAddedCmd(QtWidgets.QUndoCommand): + """ + Node added command. + + Args: + graph (NodeGraphQt.NodeGraph): node graph. + node (NodeGraphQt.NodeObject): node. + pos (tuple(float, float)): initial node position (optional). + emit_signal (bool): emit node creation signals. (default: True) + """ + + def __init__(self, graph, node, pos=None, emit_signal=True): + QtWidgets.QUndoCommand.__init__(self) + self.setText('added node') + self.graph = graph + self.node = node + self.pos = pos + self.emit_signal = emit_signal + + def undo(self): + node_id = self.node.id + self.pos = self.pos or self.node.pos() + self.graph.model.nodes.pop(self.node.id) + self.node.view.delete() + + if self.emit_signal: + self.graph.nodes_deleted.emit([node_id]) + + def redo(self): + self.graph.model.nodes[self.node.id] = self.node + self.graph.viewer().add_node(self.node.view, self.pos) + + # node width & height is calculated when it's added to the scene, + # so we have to update the node model here. + self.node.model.width = self.node.view.width + self.node.model.height = self.node.view.height + + if self.emit_signal: + self.graph.node_created.emit(self.node) + + +class NodesRemovedCmd(QtWidgets.QUndoCommand): + """ + Node deleted command. + + Args: + graph (NodeGraphQt.NodeGraph): node graph. + nodes (list[NodeGraphQt.BaseNode or NodeGraphQt.NodeObject]): nodes. + emit_signal (bool): emit node deletion signals. (default: True) + """ + + def __init__(self, graph, nodes, emit_signal=True): + QtWidgets.QUndoCommand.__init__(self) + self.setText('deleted node(s)') + self.graph = graph + self.nodes = nodes + self.emit_signal = emit_signal + + def undo(self): + for node in self.nodes: + self.graph.model.nodes[node.id] = node + self.graph.scene().addItem(node.view) + + if self.emit_signal: + self.graph.node_created.emit(node) + + def redo(self): + node_ids = [] + for node in self.nodes: + node_ids.append(node.id) + self.graph.model.nodes.pop(node.id) + node.view.delete() + + if self.emit_signal: + self.graph.nodes_deleted.emit(node_ids) + + +class NodeInputConnectedCmd(QtWidgets.QUndoCommand): + """ + "BaseNode.on_input_connected()" command. + + Args: + src_port (NodeGraphQt.Port): source port. + trg_port (NodeGraphQt.Port): target port. + """ + + def __init__(self, src_port, trg_port): + QtWidgets.QUndoCommand.__init__(self) + if src_port.type_() == PortTypeEnum.IN.value: + self.source = src_port + self.target = trg_port + else: + self.source = trg_port + self.target = src_port + + def undo(self): + node = self.source.node() + node.on_input_disconnected(self.source, self.target) + + def redo(self): + node = self.source.node() + node.on_input_connected(self.source, self.target) + + +class NodeInputDisconnectedCmd(QtWidgets.QUndoCommand): + """ + Node "on_input_disconnected()" command. + + Args: + src_port (NodeGraphQt.Port): source port. + trg_port (NodeGraphQt.Port): target port. + """ + + def __init__(self, src_port, trg_port): + QtWidgets.QUndoCommand.__init__(self) + if src_port.type_() == PortTypeEnum.IN.value: + self.source = src_port + self.target = trg_port + else: + self.source = trg_port + self.target = src_port + + def undo(self): + node = self.source.node() + node.on_input_connected(self.source, self.target) + + def redo(self): + node = self.source.node() + node.on_input_disconnected(self.source, self.target) + + +class PortConnectedCmd(QtWidgets.QUndoCommand): + """ + Port connected command. + + Args: + src_port (NodeGraphQt.Port): source port. + trg_port (NodeGraphQt.Port): target port. + emit_signal (bool): emit port connection signals. + """ + + def __init__(self, src_port, trg_port, emit_signal): + QtWidgets.QUndoCommand.__init__(self) + self.source = src_port + self.target = trg_port + self.emit_signal = emit_signal + + def undo(self): + src_model = self.source.model + trg_model = self.target.model + src_id = self.source.node().id + trg_id = self.target.node().id + + port_names = src_model.connected_ports.get(trg_id) + if port_names is []: + del src_model.connected_ports[trg_id] + if port_names and self.target.name() in port_names: + port_names.remove(self.target.name()) + + port_names = trg_model.connected_ports.get(src_id) + if port_names is []: + del trg_model.connected_ports[src_id] + if port_names and self.source.name() in port_names: + port_names.remove(self.source.name()) + + self.source.view.disconnect_from(self.target.view) + + # emit "port_disconnected" signal from the parent graph. + if self.emit_signal: + ports = {p.type_(): p for p in [self.source, self.target]} + graph = self.source.node().graph + graph.port_disconnected.emit(ports[PortTypeEnum.IN.value], + ports[PortTypeEnum.OUT.value]) + + def redo(self): + src_model = self.source.model + trg_model = self.target.model + src_id = self.source.node().id + trg_id = self.target.node().id + + src_model.connected_ports[trg_id].append(self.target.name()) + trg_model.connected_ports[src_id].append(self.source.name()) + + self.source.view.connect_to(self.target.view) + + # emit "port_connected" signal from the parent graph. + if self.emit_signal: + ports = {p.type_(): p for p in [self.source, self.target]} + graph = self.source.node().graph + graph.port_connected.emit(ports[PortTypeEnum.IN.value], + ports[PortTypeEnum.OUT.value]) + + +class PortDisconnectedCmd(QtWidgets.QUndoCommand): + """ + Port disconnected command. + + Args: + src_port (NodeGraphQt.Port): source port. + trg_port (NodeGraphQt.Port): target port. + emit_signal (bool): emit port connection signals. + """ + + def __init__(self, src_port, trg_port, emit_signal): + QtWidgets.QUndoCommand.__init__(self) + self.source = src_port + self.target = trg_port + self.emit_signal = emit_signal + + def undo(self): + src_model = self.source.model + trg_model = self.target.model + src_id = self.source.node().id + trg_id = self.target.node().id + + src_model.connected_ports[trg_id].append(self.target.name()) + trg_model.connected_ports[src_id].append(self.source.name()) + + self.source.view.connect_to(self.target.view) + + # emit "port_connected" signal from the parent graph. + if self.emit_signal: + ports = {p.type_(): p for p in [self.source, self.target]} + graph = self.source.node().graph + graph.port_connected.emit(ports[PortTypeEnum.IN.value], + ports[PortTypeEnum.OUT.value]) + + def redo(self): + src_model = self.source.model + trg_model = self.target.model + src_id = self.source.node().id + trg_id = self.target.node().id + + port_names = src_model.connected_ports.get(trg_id) + if port_names is []: + del src_model.connected_ports[trg_id] + if port_names and self.target.name() in port_names: + port_names.remove(self.target.name()) + + port_names = trg_model.connected_ports.get(src_id) + if port_names is []: + del trg_model.connected_ports[src_id] + if port_names and self.source.name() in port_names: + port_names.remove(self.source.name()) + + self.source.view.disconnect_from(self.target.view) + + # emit "port_disconnected" signal from the parent graph. + if self.emit_signal: + ports = {p.type_(): p for p in [self.source, self.target]} + graph = self.source.node().graph + graph.port_disconnected.emit(ports[PortTypeEnum.IN.value], + ports[PortTypeEnum.OUT.value]) + + +class PortLockedCmd(QtWidgets.QUndoCommand): + """ + Port locked command. + + Args: + port (NodeGraphQt.Port): node port. + """ + + def __init__(self, port): + QtWidgets.QUndoCommand.__init__(self) + self.setText('lock port "{}"'.format(port.name())) + self.port = port + + def undo(self): + self.port.model.locked = False + self.port.view.locked = False + + def redo(self): + self.port.model.locked = True + self.port.view.locked = True + + +class PortUnlockedCmd(QtWidgets.QUndoCommand): + """ + Port unlocked command. + + Args: + port (NodeGraphQt.Port): node port. + """ + + def __init__(self, port): + QtWidgets.QUndoCommand.__init__(self) + self.setText('unlock port "{}"'.format(port.name())) + self.port = port + + def undo(self): + self.port.model.locked = True + self.port.view.locked = True + + def redo(self): + self.port.model.locked = False + self.port.view.locked = False + + +class PortVisibleCmd(QtWidgets.QUndoCommand): + """ + Port visibility command. + + Args: + port (NodeGraphQt.Port): node port. + """ + + def __init__(self, port, visible): + QtWidgets.QUndoCommand.__init__(self) + self.port = port + self.visible = visible + if visible: + self.setText('show port {}'.format(self.port.name())) + else: + self.setText('hide port {}'.format(self.port.name())) + + def set_visible(self, visible): + self.port.model.visible = visible + self.port.view.setVisible(visible) + node_view = self.port.node().view + text_item = None + if self.port.type_() == PortTypeEnum.IN.value: + text_item = node_view.get_input_text_item(self.port.view) + elif self.port.type_() == PortTypeEnum.OUT.value: + text_item = node_view.get_output_text_item(self.port.view) + if text_item: + text_item.setVisible(visible) + + node_view.draw_node() + + # redraw the connected pipes in the scene. + ports = node_view.inputs + node_view.outputs + for port in node_view.inputs + node_view.outputs: + for pipe in port.connected_pipes: + pipe.update() + + def undo(self): + self.set_visible(not self.visible) + + def redo(self): + self.set_visible(self.visible) diff --git a/cuegui/NodeGraphQt/base/factory.py b/cuegui/NodeGraphQt/base/factory.py new file mode 100644 index 000000000..ac209c75c --- /dev/null +++ b/cuegui/NodeGraphQt/base/factory.py @@ -0,0 +1,102 @@ +#!/usr/bin/python +from NodeGraphQt.errors import NodeRegistrationError + + +class NodeFactory(object): + """ + Node factory that stores all the node types. + """ + + def __init__(self): + self.__aliases = {} + self.__names = {} + self.__nodes = {} + + @property + def names(self): + """ + Return all currently registered node type identifiers. + + Returns: + dict: key='.format( + self.__class__.__name__, hex(id(self))) + + def _register_context_menu(self): + """ + Register the default context menus. + """ + if not self._viewer: + return + menus = self._viewer.context_menus() + if menus.get('graph'): + self._context_menu['graph'] = NodeGraphMenu(self, menus['graph']) + if menus.get('nodes'): + self._context_menu['nodes'] = NodesMenu(self, menus['nodes']) + + def _register_builtin_nodes(self): + """ + Register the default builtin nodes to the :meth:`NodeGraph.node_factory` + """ + self.register_node(BackdropNode, alias='Backdrop') + + def _wire_signals(self): + """ + Connect up all the signals and slots here. + """ + + # internal signals. + self._viewer.search_triggered.connect(self._on_search_triggered) + self._viewer.connection_sliced.connect(self._on_connection_sliced) + self._viewer.connection_changed.connect(self._on_connection_changed) + self._viewer.moved_nodes.connect(self._on_nodes_moved) + self._viewer.node_double_clicked.connect(self._on_node_double_clicked) + self._viewer.node_name_changed.connect(self._on_node_name_changed) + self._viewer.node_backdrop_updated.connect( + self._on_node_backdrop_updated) + self._viewer.insert_node.connect(self._on_insert_node) + + # pass through translated signals. + self._viewer.node_selected.connect(self._on_node_selected) + self._viewer.node_selection_changed.connect( + self._on_node_selection_changed) + self._viewer.data_dropped.connect(self._on_node_data_dropped) + self._viewer.context_menu_prompt.connect(self._on_context_menu_prompt) + + def _on_context_menu_prompt(self, menu_name, node_id): + """ + Slot function triggered just before a context menu is shown. + + Args: + menu_name (str): context menu name. + node_id (str): node id if triggered from the nodes context menu. + """ + node = self.get_node_by_id(node_id) + menu = self.get_context_menu(menu_name) + self.context_menu_prompt.emit(menu, node) + + def _on_insert_node(self, pipe, node_id, prev_node_pos): + """ + Slot function triggered when a selected node has collided with a pipe. + + Args: + pipe (Pipe): collided pipe item. + node_id (str): selected node id to insert. + prev_node_pos (dict): previous node position. {NodeItem: [prev_x, prev_y]} + """ + node = self.get_node_by_id(node_id) + + # exclude if not a BaseNode + if not isinstance(node, BaseNode): + return + + disconnected = [(pipe.input_port, pipe.output_port)] + connected = [] + + if node.input_ports(): + connected.append( + (pipe.output_port, node.input_ports()[0].view) + ) + if node.output_ports(): + connected.append( + (node.output_ports()[0].view, pipe.input_port) + ) + + self._undo_stack.beginMacro('inserted node') + self._on_connection_changed(disconnected, connected) + self._on_nodes_moved(prev_node_pos) + self._undo_stack.endMacro() + + def _on_property_bin_changed(self, node_id, prop_name, prop_value): + """ + called when a property widget has changed in a properties bin. + (emits the node object, property name, property value) + + Args: + node_id (str): node id. + prop_name (str): node property name. + prop_value (object): python built in types. + """ + node = self.get_node_by_id(node_id) + + # prevent signals from causing a infinite loop. + if node.get_property(prop_name) != prop_value: + node.set_property(prop_name, prop_value) + + def _on_node_name_changed(self, node_id, name): + """ + called when a node text qgraphics item in the viewer is edited. + (sets the name through the node object so undo commands are registered.) + + Args: + node_id (str): node id emitted by the viewer. + name (str): new node name. + """ + node = self.get_node_by_id(node_id) + node.set_name(name) + + # TODO: not sure about redrawing the node here. + node.view.draw_node() + + def _on_node_double_clicked(self, node_id): + """ + called when a node in the viewer is double click. + (emits the node object when the node is clicked) + + Args: + node_id (str): node id emitted by the viewer. + """ + node = self.get_node_by_id(node_id) + self.node_double_clicked.emit(node) + + def _on_node_selected(self, node_id): + """ + called when a node in the viewer is selected on left click. + (emits the node object when the node is clicked) + + Args: + node_id (str): node id emitted by the viewer. + """ + node = self.get_node_by_id(node_id) + self.node_selected.emit(node) + + def _on_node_selection_changed(self, sel_ids, desel_ids): + """ + called when the node selection changes in the viewer. + (emits node objects , ) + + Args: + sel_ids (list[str]): new selected node ids. + desel_ids (list[str]): deselected node ids. + """ + sel_nodes = [self.get_node_by_id(nid) for nid in sel_ids] + unsel_nodes = [self.get_node_by_id(nid) for nid in desel_ids] + self.node_selection_changed.emit(sel_nodes, unsel_nodes) + + def _on_node_data_dropped(self, mimedata, pos): + """ + called when data has been dropped on the viewer. + + Example Identifiers: + URI = ngqt://path/to/node/session.graph + URN = ngqt::node:com.nodes.MyNode1;node:com.nodes.MyNode2 + + Args: + mimedata (QtCore.QMimeData): mime data. + pos (QtCore.QPoint): scene position relative to the drop. + """ + uri_regex = re.compile(r'{}(?:/*)([\w/]+)(\.\w+)'.format(URI_SCHEME)) + urn_regex = re.compile(r'{}([\w\.:;]+)'.format(URN_SCHEME)) + if mimedata.hasFormat(MIME_TYPE): + data = mimedata.data(MIME_TYPE).data().decode() + urn_search = urn_regex.search(data) + if urn_search: + search_str = urn_search.group(1) + node_ids = sorted(re.findall(r'node:([\w\.]+)', search_str)) + x, y = pos.x(), pos.y() + for node_id in node_ids: + self.create_node(node_id, pos=[x, y]) + x += 80 + y += 80 + elif mimedata.hasFormat('text/uri-list'): + not_supported_urls = [] + for url in mimedata.urls(): + local_file = url.toLocalFile() + if local_file: + try: + self.import_session(local_file) + continue + except Exception as e: + not_supported_urls.append(url) + + url_str = url.toString() + if url_str: + uri_search = uri_regex.search(url_str) + if uri_search: + path = uri_search.group(1) + ext = uri_search.group(2) + try: + self.import_session('{}{}'.format(path, ext)) + except Exception as e: + not_supported_urls.append(url) + + if not_supported_urls: + print( + 'Can\'t import the following urls: \n{}' + .format('\n'.join(not_supported_urls)) + ) + self.data_dropped.emit(mimedata, pos) + else: + self.data_dropped.emit(mimedata, pos) + + def _on_nodes_moved(self, node_data): + """ + called when selected nodes in the viewer has changed position. + + Args: + node_data (dict): {: } + """ + self._undo_stack.beginMacro('move nodes') + for node_view, prev_pos in node_data.items(): + node = self._model.nodes[node_view.id] + self._undo_stack.push(NodeMovedCmd(node, node.pos(), prev_pos)) + self._undo_stack.endMacro() + + def _on_node_backdrop_updated(self, node_id, update_property, value): + """ + called when a BackdropNode is updated. + + Args: + node_id (str): backdrop node id. + value (str): update type. + """ + backdrop = self.get_node_by_id(node_id) + if backdrop and isinstance(backdrop, BackdropNode): + backdrop.on_backdrop_updated(update_property, value) + + def _on_search_triggered(self, node_type, pos): + """ + called when the tab search widget is triggered in the viewer. + + Args: + node_type (str): node identifier. + pos (tuple or list): x, y position for the node. + """ + self.create_node(node_type, pos=pos) + + def _on_connection_changed(self, disconnected, connected): + """ + called when a pipe connection has been changed in the viewer. + + Args: + disconnected (list[list[widgets.port.PortItem]): + pair list of port view items. + connected (list[list[widgets.port.PortItem]]): + pair list of port view items. + """ + if not (disconnected or connected): + return + + label = 'connect node(s)' if connected else 'disconnect node(s)' + ptypes = {PortTypeEnum.IN.value: 'inputs', + PortTypeEnum.OUT.value: 'outputs'} + + self._undo_stack.beginMacro(label) + for p1_view, p2_view in disconnected: + node1 = self._model.nodes[p1_view.node.id] + node2 = self._model.nodes[p2_view.node.id] + port1 = getattr(node1, ptypes[p1_view.port_type])()[p1_view.name] + port2 = getattr(node2, ptypes[p2_view.port_type])()[p2_view.name] + port1.disconnect_from(port2) + for p1_view, p2_view in connected: + node1 = self._model.nodes[p1_view.node.id] + node2 = self._model.nodes[p2_view.node.id] + port1 = getattr(node1, ptypes[p1_view.port_type])()[p1_view.name] + port2 = getattr(node2, ptypes[p2_view.port_type])()[p2_view.name] + port1.connect_to(port2) + self._undo_stack.endMacro() + + def _on_connection_sliced(self, ports): + """ + slot when connection pipes have been sliced. + + Args: + ports (list[list[widgets.port.PortItem]]): + pair list of port connections (in port, out port) + """ + if not ports: + return + ptypes = {PortTypeEnum.IN.value: 'inputs', + PortTypeEnum.OUT.value: 'outputs'} + self._undo_stack.beginMacro('slice connections') + for p1_view, p2_view in ports: + node1 = self._model.nodes[p1_view.node.id] + node2 = self._model.nodes[p2_view.node.id] + port1 = getattr(node1, ptypes[p1_view.port_type])()[p1_view.name] + port2 = getattr(node2, ptypes[p2_view.port_type])()[p2_view.name] + port1.disconnect_from(port2) + self._undo_stack.endMacro() + + @property + def model(self): + """ + The model used for storing the node graph data. + + Returns: + NodeGraphQt.base.model.NodeGraphModel: node graph model. + """ + return self._model + + @property + def node_factory(self): + """ + Return the node factory object used by the node graph. + + Returns: + NodeFactory: node factory. + """ + return self._node_factory + + @property + def widget(self): + """ + The node graph widget for adding into a layout. + + Returns: + NodeGraphWidget: node graph widget. + """ + if self._widget is None: + self._widget = NodeGraphWidget() + self._widget.addTab(self._viewer, 'Node Graph') + # hide the close button on the first tab. + tab_bar = self._widget.tabBar() + for btn_flag in [tab_bar.ButtonPosition.RightSide, tab_bar.ButtonPosition.LeftSide]: + tab_btn = tab_bar.tabButton(0, btn_flag) + if tab_btn: + tab_btn.deleteLater() + tab_bar.setTabButton(0, btn_flag, None) + self._widget.tabCloseRequested.connect( + self._on_close_sub_graph_tab + ) + return self._widget + + @property + def undo_view(self): + """ + Returns node graph undo history list widget. + + Returns: + PySide2.QtWidgets.QUndoView: node graph undo view. + """ + if self._undo_view is None: + self._undo_view = QtWidgets.QUndoView(self._undo_stack) + self._undo_view.setWindowTitle('Undo History') + return self._undo_view + + def cursor_pos(self): + """ + Returns the cursor last position in the node graph. + + Returns: + tuple(float, float): cursor x,y coordinates of the scene. + """ + cursor_pos = self.viewer().scene_cursor_pos() + if not cursor_pos: + return 0.0, 0.0 + return cursor_pos.x(), cursor_pos.y() + + def toggle_node_search(self): + """ + toggle the node search widget visibility. + """ + if self._viewer.underMouse(): + self._viewer.tab_search_set_nodes(self._node_factory.names) + self._viewer.tab_search_toggle() + + def show(self): + """ + Show node graph widget this is just a convenience + function to :meth:`NodeGraph.widget.show()`. + """ + self.widget.show() + + def close(self): + """ + Close node graph NodeViewer widget this is just a convenience + function to :meth:`NodeGraph.widget.close()`. + """ + self.widget.close() + + def viewer(self): + """ + Returns the internal view interface used by the node graph. + + Warnings: + Methods in the ``NodeViewer`` are used internally + by ``NodeGraphQt`` components to get the widget use + :attr:`NodeGraph.widget`. + + See Also: + :attr:`NodeGraph.widget` to add the node graph widget into a + :class:`PySide2.QtWidgets.QLayout`. + + Returns: + NodeGraphQt.widgets.viewer.NodeViewer: viewer interface. + """ + return self._viewer + + def scene(self): + """ + Returns the ``QGraphicsScene`` object used in the node graph. + + Returns: + NodeGraphQt.widgets.scene.NodeScene: node scene. + """ + return self._viewer.scene() + + def background_color(self): + """ + Return the node graph background color. + + Returns: + tuple: r, g ,b + """ + return self.scene().background_color + + def set_background_color(self, r, g, b): + """ + Set node graph background color. + + Args: + r (int): red value. + g (int): green value. + b (int): blue value. + """ + self.scene().background_color = (r, g, b) + self._viewer.force_update() + + def grid_color(self): + """ + Return the node graph grid color. + + Returns: + tuple: r, g ,b + """ + return self.scene().grid_color + + def set_grid_color(self, r, g, b): + """ + Set node graph grid color. + + Args: + r (int): red value. + g (int): green value. + b (int): blue value. + """ + self.scene().grid_color = (r, g, b) + self._viewer.force_update() + + def set_grid_mode(self, mode=None): + """ + Set node graph background grid mode. + + (default: :attr:`NodeGraphQt.constants.ViewerEnum.GRID_DISPLAY_LINES`). + + See: :attr:`NodeGraphQt.constants.ViewerEnum` + + .. code-block:: python + :linenos: + + graph = NodeGraph() + graph.set_grid_mode(ViewerEnum.GRID_DISPLAY_DOTS.value) + + Args: + mode (int): background style. + """ + display_types = [ + ViewerEnum.GRID_DISPLAY_NONE.value, + ViewerEnum.GRID_DISPLAY_DOTS.value, + ViewerEnum.GRID_DISPLAY_LINES.value + ] + if mode not in display_types: + mode = ViewerEnum.GRID_DISPLAY_LINES.value + self.scene().grid_mode = mode + self._viewer.force_update() + + def add_properties_bin(self, prop_bin): + """ + Wire up a properties bin widget to the node graph. + + Args: + prop_bin (NodeGraphQt.PropertiesBinWidget): properties widget. + """ + prop_bin.property_changed.connect(self._on_property_bin_changed) + + def undo_stack(self): + """ + Returns the undo stack used in the node graph. + + See Also: + :meth:`NodeGraph.begin_undo()`, + :meth:`NodeGraph.end_undo()` + + Returns: + QtWidgets.QUndoStack: undo stack. + """ + return self._undo_stack + + def clear_undo_stack(self): + """ + Clears the undo stack. + + Note: + Convenience function to + :meth:`NodeGraph.undo_stack().clear()` + + See Also: + :meth:`NodeGraph.begin_undo()`, + :meth:`NodeGraph.end_undo()`, + :meth:`NodeGraph.undo_stack()` + """ + self._undo_stack.clear() + + def begin_undo(self, name): + """ + Start of an undo block followed by a + :meth:`NodeGraph.end_undo()`. + + Args: + name (str): name for the undo block. + """ + self._undo_stack.beginMacro(name) + + def end_undo(self): + """ + End of an undo block started by + :meth:`NodeGraph.begin_undo()`. + """ + self._undo_stack.endMacro() + + def context_menu(self): + """ + Returns the context menu for the node graph. + + Note: + This is a convenience function to + :meth:`NodeGraph.get_context_menu` + with the arg ``menu="graph"`` + + Returns: + NodeGraphQt.NodeGraphMenu: context menu object. + """ + return self.get_context_menu('graph') + + def context_nodes_menu(self): + """ + Returns the context menu for the nodes. + + Note: + This is a convenience function to + :meth:`NodeGraph.get_context_menu` + with the arg ``menu="nodes"`` + + Returns: + NodeGraphQt.NodesMenu: context menu object. + """ + return self.get_context_menu('nodes') + + def get_context_menu(self, menu): + """ + Returns the context menu specified by the name. + + menu types: + + - ``"graph"`` context menu from the node graph. + - ``"nodes"`` context menu for the nodes. + + Args: + menu (str): menu name. + + Returns: + NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu: context menu object. + """ + return self._context_menu.get(menu) + + def _deserialize_context_menu(self, menu, menu_data): + """ + Populate context menu from a dictionary. + + Args: + menu (NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu): + parent context menu. + menu_data (list[dict] or dict): serialized menu data. + """ + if not menu: + raise ValueError('No context menu named: "{}"'.format(menu)) + + import sys + import importlib.util + + nodes_menu = self.get_context_menu('nodes') + + def build_menu_command(menu, data): + """ + Create menu command from serialized data. + + Args: + menu (NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu): + menu object. + data (dict): serialized menu command data. + """ + full_path = os.path.abspath(data['file']) + base_dir, file_name = os.path.split(full_path) + base_name = os.path.basename(base_dir) + file_name, _ = file_name.split('.') + + mod_name = '{}.{}'.format(base_name, file_name) + + spec = importlib.util.spec_from_file_location(mod_name, full_path) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + + cmd_func = getattr(mod, data['function_name']) + cmd_name = data.get('label') or '' + cmd_shortcut = data.get('shortcut') + cmd_kwargs = {'func': cmd_func, 'shortcut': cmd_shortcut} + + if menu == nodes_menu and data.get('node_type'): + cmd_kwargs['node_type'] = data['node_type'] + + menu.add_command(name=cmd_name, **cmd_kwargs) + + if isinstance(menu_data, dict): + item_type = menu_data.get('type') + if item_type == 'separator': + menu.add_separator() + elif item_type == 'command': + build_menu_command(menu, menu_data) + elif item_type == 'menu': + sub_menu = menu.add_menu(menu_data['label']) + items = menu_data.get('items', []) + self._deserialize_context_menu(sub_menu, items) + elif isinstance(menu_data, list): + for item_data in menu_data: + self._deserialize_context_menu(menu, item_data) + + def set_context_menu(self, menu_name, data): + """ + Populate a context menu from serialized data. + + example of serialized menu data: + + .. highlight:: python + .. code-block:: python + + [ + { + 'type': 'menu', + 'label': 'node sub menu', + 'items': [ + { + 'type': 'command', + 'label': 'test command', + 'file': '../path/to/my/test_module.py', + 'function': 'run_test', + 'node_type': 'nodeGraphQt.nodes.MyNodeClass' + }, + + ] + }, + ] + + the ``run_test`` example function: + + .. highlight:: python + .. code-block:: python + + def run_test(graph): + print(graph.selected_nodes()) + + + Args: + menu_name (str): name of the parent context menu to populate under. + data (dict): serialized menu data. + """ + context_menu = self.get_context_menu(menu_name) + self._deserialize_context_menu(context_menu, data) + + def set_context_menu_from_file(self, file_path, menu=None): + """ + Populate a context menu from a serialized json file. + + menu types: + + - ``"graph"`` context menu from the node graph. + - ``"nodes"`` context menu for the nodes. + + Args: + menu (str): name of the parent context menu to populate under. + file_path (str): serialized menu commands json file. + """ + file_path = os.path.abspath(file_path) + + menu = menu or 'graph' + if not os.path.isfile(file_path): + raise IOError('file doesn\'t exists: "{}"'.format(file_path)) + + with open(file_path) as f: + data = json.load(f) + context_menu = self.get_context_menu(menu) + self._deserialize_context_menu(context_menu, data) + + def disable_context_menu(self, disabled=True, name='all'): + """ + Disable/Enable context menus from the node graph. + + menu types: + + - ``"all"`` all context menus from the node graph. + - ``"graph"`` context menu from the node graph. + - ``"nodes"`` context menu for the nodes. + + Args: + disabled (bool): true to enable context menu. + name (str): menu name. (default: ``"all"``) + """ + if name == 'all': + for k, menu in self._viewer.context_menus().items(): + menu.setDisabled(disabled) + menu.setVisible(not disabled) + return + menus = self._viewer.context_menus() + if menus.get(name): + menus[name].setDisabled(disabled) + menus[name].setVisible(not disabled) + + def acyclic(self): + """ + Returns true if the current node graph is acyclic. + + See Also: + :meth:`NodeGraph.set_acyclic` + + Returns: + bool: true if acyclic (default: ``True``). + """ + return self._model.acyclic + + def set_acyclic(self, mode=True): + """ + Enable the node graph to be a acyclic graph. (default: ``True``) + + See Also: + :meth:`NodeGraph.acyclic` + + Args: + mode (bool): true to enable acyclic. + """ + self._model.acyclic = mode + self._viewer.acyclic = self._model.acyclic + + def pipe_collision(self): + """ + Returns if pipe collision is enabled. + + See Also: + To enable/disable pipe collision + :meth:`NodeGraph.set_pipe_collision` + + Returns: + bool: True if pipe collision is enabled. + """ + return self._model.pipe_collision + + def set_pipe_collision(self, mode=True): + """ + Enable/Disable pipe collision. + + When enabled dragging a node over a pipe will allow the node to be + inserted as a new connection between the pipe. + + See Also: + :meth:`NodeGraph.pipe_collision` + + Args: + mode (bool): False to disable pipe collision. + """ + self._model.pipe_collision = mode + self._viewer.pipe_collision = self._model.pipe_collision + + def pipe_slicing(self): + """ + Returns if pipe slicing is enabled. + + See Also: + To enable/disable pipe slicer + :meth:`NodeGraph.set_pipe_slicing` + + Returns: + bool: True if pipe slicing is enabled. + """ + return self._model.pipe_slicing + + def set_pipe_slicing(self, mode=True): + """ + Enable/Disable pipe slicer. + + When set to true holding down ``Alt + Shift + LMB Drag`` will allow node + pipe connections to be sliced. + + .. image:: ../_images/slicer.png + :width: 400px + + See Also: + :meth:`NodeGraph.pipe_slicing` + + Args: + mode (bool): False to disable the slicer pipe. + """ + self._model.pipe_slicing = mode + self._viewer.pipe_slicing = self._model.pipe_slicing + + def pipe_style(self): + """ + Returns the current pipe layout style. + + See Also: + :meth:`NodeGraph.set_pipe_style` + + Returns: + int: pipe style value. :attr:`NodeGraphQt.constants.PipeLayoutEnum` + """ + return self._model.pipe_style + + def set_pipe_style(self, style=PipeLayoutEnum.CURVED.value): + """ + Set node graph pipes to be drawn as curved `(default)`, straight or angled. + + .. code-block:: python + :linenos: + + graph = NodeGraph() + graph.set_pipe_style(PipeLayoutEnum.CURVED.value) + + See: :attr:`NodeGraphQt.constants.PipeLayoutEnum` + + .. image:: ../_images/pipe_layout_types.gif + :width: 80% + + + Args: + style (int): pipe layout style. + """ + pipe_max = max([PipeLayoutEnum.CURVED.value, + PipeLayoutEnum.STRAIGHT.value, + PipeLayoutEnum.ANGLE.value]) + style = style if 0 <= style <= pipe_max else PipeLayoutEnum.CURVED.value + self._model.pipe_style = style + self._viewer.set_pipe_layout(style) + + def layout_direction(self): + """ + Return the current node graph layout direction. + + `Implemented in` ``v0.3.0`` + + See Also: + :meth:`NodeGraph.set_layout_direction` + + Returns: + int: layout direction. + """ + return self._model.layout_direction + + def set_layout_direction(self, direction): + """ + Sets the node graph layout direction to horizontal or vertical. + This function will also override the layout direction on all + nodes in the current node graph. + + `Implemented in` ``v0.3.0`` + + **Layout Types:** + + - :attr:`NodeGraphQt.constants.LayoutDirectionEnum.HORIZONTAL` + - :attr:`NodeGraphQt.constants.LayoutDirectionEnum.VERTICAL` + + .. image:: ../_images/layout_direction_switch.gif + :width: 300px + + Warnings: + This function does not register to the undo stack. + + See Also: + :meth:`NodeGraph.layout_direction`, + :meth:`NodeObject.set_layout_direction` + + Args: + direction (int): layout direction. + """ + direction_types = [e.value for e in LayoutDirectionEnum] + if direction not in direction_types: + direction = LayoutDirectionEnum.HORIZONTAL.value + self._model.layout_direction = direction + for node in self.all_nodes(): + node.set_layout_direction(direction) + self._viewer.set_layout_direction(direction) + + def fit_to_selection(self): + """ + Sets the zoom level to fit selected nodes. + If no nodes are selected then all nodes in the graph will be framed. + """ + nodes = self.selected_nodes() or self.all_nodes() + if not nodes: + return + self._viewer.zoom_to_nodes([n.view for n in nodes]) + + def reset_zoom(self): + """ + Reset the zoom level + """ + self._viewer.reset_zoom() + + def set_zoom(self, zoom=0): + """ + Set the zoom factor of the Node Graph the default is ``0.0`` + + Args: + zoom (float): zoom factor (max zoom out ``-0.9`` / max zoom in ``2.0``) + """ + self._viewer.set_zoom(zoom) + + def get_zoom(self): + """ + Get the current zoom level of the node graph. + + Returns: + float: the current zoom level. + """ + return self._viewer.get_zoom() + + def center_on(self, nodes=None): + """ + Center the node graph on the given nodes or all nodes by default. + + Args: + nodes (list[NodeGraphQt.BaseNode]): a list of nodes. + """ + nodes = nodes or [] + self._viewer.center_selection([n.view for n in nodes]) + + def center_selection(self): + """ + Centers on the current selected nodes. + """ + nodes = self._viewer.selected_nodes() + self._viewer.center_selection(nodes) + + def registered_nodes(self): + """ + Return a list of all node types that have been registered. + + See Also: + To register a node :meth:`NodeGraph.register_node` + + Returns: + list[str]: list of node type identifiers. + """ + return sorted(self._node_factory.nodes.keys()) + + def register_node(self, node, alias=None): + """ + Register the node to the :meth:`NodeGraph.node_factory` + + Args: + node (NodeGraphQt.NodeObject): node object. + alias (str): custom alias name for the node type. + """ + self._node_factory.register_node(node, alias) + self._viewer.rebuild_tab_search() + self.nodes_registered.emit([node]) + + def register_nodes(self, nodes): + """ + Register the nodes to the :meth:`NodeGraph.node_factory` + + Args: + nodes (list): list of nodes. + """ + [self._node_factory.register_node(n) for n in nodes] + self._viewer.rebuild_tab_search() + self.nodes_registered.emit(nodes) + + def create_node(self, node_type, name=None, selected=True, color=None, + text_color=None, pos=None, push_undo=True): + """ + Create a new node in the node graph. + + See Also: + To list all node types :meth:`NodeGraph.registered_nodes` + + Args: + node_type (str): node instance type. + name (str): set name of the node. + selected (bool): set created node to be selected. + color (tuple or str): node color ``(255, 255, 255)`` or ``"#FFFFFF"``. + text_color (tuple or str): text color ``(255, 255, 255)`` or ``"#FFFFFF"``. + pos (list[int, int]): initial x, y position for the node (default: ``(0, 0)``). + push_undo (bool): register the command to the undo stack. (default: True) + + Returns: + BaseNode: the created instance of the node. + """ + node = self._node_factory.create_node_instance(node_type) + if node: + node._graph = self + node.model._graph_model = self.model + + wid_types = node.model.__dict__.pop('_TEMP_property_widget_types') + prop_attrs = node.model.__dict__.pop('_TEMP_property_attrs') + + if self.model.get_node_common_properties(node.type_) is None: + node_attrs = {node.type_: { + n: {'widget_type': wt} for n, wt in wid_types.items() + }} + for pname, pattrs in prop_attrs.items(): + node_attrs[node.type_][pname].update(pattrs) + self.model.set_node_common_properties(node_attrs) + + accept_types = node.model.__dict__.pop( + '_TEMP_accept_connection_types' + ) + for ptype, pdata in accept_types.get(node.type_, {}).items(): + for pname, accept_data in pdata.items(): + for accept_ntype, accept_ndata in accept_data.items(): + for accept_ptype, accept_pnames in accept_ndata.items(): + for accept_pname in accept_pnames: + self._model.add_port_accept_connection_type( + port_name=pname, + port_type=ptype, + node_type=node.type_, + accept_pname=accept_pname, + accept_ptype=accept_ptype, + accept_ntype=accept_ntype + ) + reject_types = node.model.__dict__.pop( + '_TEMP_reject_connection_types' + ) + for ptype, pdata in reject_types.get(node.type_, {}).items(): + for pname, reject_data in pdata.items(): + for reject_ntype, reject_ndata in reject_data.items(): + for reject_ptype, reject_pnames in reject_ndata.items(): + for reject_pname in reject_pnames: + self._model.add_port_reject_connection_type( + port_name=pname, + port_type=ptype, + node_type=node.type_, + reject_pname=reject_pname, + reject_ptype=reject_ptype, + reject_ntype=reject_ntype + ) + + node.NODE_NAME = self.get_unique_name(name or node.NODE_NAME) + node.model.name = node.NODE_NAME + node.model.selected = selected + + def format_color(clr): + if isinstance(clr, str): + clr = clr.strip('#') + return tuple(int(clr[i:i + 2], 16) for i in (0, 2, 4)) + return clr + + if color: + node.model.color = format_color(color) + if text_color: + node.model.text_color = format_color(text_color) + if pos: + node.model.pos = [float(pos[0]), float(pos[1])] + + # initial node direction layout. + node.model.layout_direction = self.layout_direction() + + node.update() + + undo_cmd = NodeAddedCmd( + self, node, pos=node.model.pos, emit_signal=True + ) + if push_undo: + undo_label = 'create node: "{}"'.format(node.NODE_NAME) + self._undo_stack.beginMacro(undo_label) + for n in self.selected_nodes(): + n.set_property('selected', False, push_undo=True) + self._undo_stack.push(undo_cmd) + self._undo_stack.endMacro() + else: + for n in self.selected_nodes(): + n.set_property('selected', False, push_undo=False) + undo_cmd.redo() + + return node + + raise NodeCreationError('Can\'t find node: "{}"'.format(node_type)) + + def add_node(self, node, pos=None, selected=True, push_undo=True): + """ + Add a node into the node graph. + unlike the :meth:`NodeGraph.create_node` function this will not + trigger the :attr:`NodeGraph.node_created` signal. + + Args: + node (NodeGraphQt.BaseNode): node object. + pos (list[float]): node x,y position. (optional) + selected (bool): node selected state. (optional) + push_undo (bool): register the command to the undo stack. (default: True) + """ + assert isinstance(node, NodeObject), 'node must be a Node instance.' + + wid_types = node.model.__dict__.pop('_TEMP_property_widget_types') + prop_attrs = node.model.__dict__.pop('_TEMP_property_attrs') + + if self.model.get_node_common_properties(node.type_) is None: + node_attrs = {node.type_: { + n: {'widget_type': wt} for n, wt in wid_types.items() + }} + for pname, pattrs in prop_attrs.items(): + node_attrs[node.type_][pname].update(pattrs) + self.model.set_node_common_properties(node_attrs) + + accept_types = node.model.__dict__.pop( + '_TEMP_accept_connection_types' + ) + for ptype, pdata in accept_types.get(node.type_, {}).items(): + for pname, accept_data in pdata.items(): + for accept_ntype, accept_ndata in accept_data.items(): + for accept_ptype, accept_pnames in accept_ndata.items(): + for accept_pname in accept_pnames: + self._model.add_port_accept_connection_type( + port_name=pname, + port_type=ptype, + node_type=node.type_, + accept_pname=accept_pname, + accept_ptype=accept_ptype, + accept_ntype=accept_ntype + ) + reject_types = node.model.__dict__.pop( + '_TEMP_reject_connection_types' + ) + for ptype, pdata in reject_types.get(node.type_, {}).items(): + for pname, reject_data in pdata.items(): + for reject_ntype, reject_ndata in reject_data.items(): + for reject_ptype, reject_pnames in reject_ndata.items(): + for reject_pname in reject_pnames: + self._model.add_port_reject_connection_type( + port_name=pname, + port_type=ptype, + node_type=node.type_, + reject_pname=reject_pname, + reject_ptype=reject_ptype, + reject_ntype=reject_ntype + ) + + node._graph = self + node.NODE_NAME = self.get_unique_name(node.NODE_NAME) + node.model._graph_model = self.model + node.model.name = node.NODE_NAME + + # initial node direction layout. + node.model.layout_direction = self.layout_direction() + + # update method must be called before it's been added to the viewer. + node.update() + + undo_cmd = NodeAddedCmd(self, node, pos=pos, emit_signal=False) + if push_undo: + self._undo_stack.beginMacro('add node: "{}"'.format(node.name())) + self._undo_stack.push(undo_cmd) + if selected: + node.set_selected(True) + self._undo_stack.endMacro() + else: + undo_cmd.redo() + + def delete_node(self, node, push_undo=True): + """ + Remove the node from the node graph. + + Args: + node (NodeGraphQt.BaseNode): node object. + push_undo (bool): register the command to the undo stack. (default: True) + """ + assert isinstance(node, NodeObject), \ + 'node must be a instance of a NodeObject.' + node_id = node.id + if push_undo: + self._undo_stack.beginMacro('delete node: "{}"'.format(node.name())) + + if isinstance(node, BaseNode): + for p in node.input_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=push_undo) + p.clear_connections(push_undo=push_undo) + for p in node.output_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=push_undo) + p.clear_connections(push_undo=push_undo) + + # collapse group node before removing. + if isinstance(node, GroupNode) and node.is_expanded: + node.collapse() + + undo_cmd = NodesRemovedCmd(self, [node], emit_signal=True) + if push_undo: + self._undo_stack.push(undo_cmd) + self._undo_stack.endMacro() + else: + undo_cmd.redo() + + def remove_node(self, node, push_undo=True): + """ + Remove the node from the node graph. + + unlike the :meth:`NodeGraph.delete_node` function this will not + trigger the :attr:`NodeGraph.nodes_deleted` signal. + + Args: + node (NodeGraphQt.BaseNode): node object. + push_undo (bool): register the command to the undo stack. (default: True) + + """ + assert isinstance(node, NodeObject), 'node must be a Node instance.' + + if push_undo: + self._undo_stack.beginMacro('delete node: "{}"'.format(node.name())) + + # collapse group node before removing. + if isinstance(node, GroupNode) and node.is_expanded: + node.collapse() + + if isinstance(node, BaseNode): + for p in node.input_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=push_undo) + p.clear_connections(push_undo=push_undo) + for p in node.output_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=push_undo) + p.clear_connections(push_undo=push_undo) + + undo_cmd = NodesRemovedCmd(self, [node], emit_signal=False) + if push_undo: + self._undo_stack.push(undo_cmd) + self._undo_stack.endMacro() + else: + undo_cmd.redo() + + def delete_nodes(self, nodes, push_undo=True): + """ + Remove a list of specified nodes from the node graph. + + Args: + nodes (list[NodeGraphQt.BaseNode]): list of node instances. + push_undo (bool): register the command to the undo stack. (default: True) + """ + if not nodes: + return + if len(nodes) == 1: + self.delete_node(nodes[0], push_undo=push_undo) + return + node_ids = [n.id for n in nodes] + if push_undo: + self._undo_stack.beginMacro( + 'deleted "{}" node(s)'.format(len(nodes)) + ) + for node in nodes: + + # collapse group node before removing. + if isinstance(node, GroupNode) and node.is_expanded: + node.collapse() + + if isinstance(node, BaseNode): + for p in node.input_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=push_undo) + p.clear_connections(push_undo=push_undo) + for p in node.output_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=push_undo) + p.clear_connections(push_undo=push_undo) + + undo_cmd = NodesRemovedCmd(self, nodes, emit_signal=True) + if push_undo: + self._undo_stack.push(undo_cmd) + self._undo_stack.endMacro() + else: + undo_cmd.redo() + + self.nodes_deleted.emit(node_ids) + + def extract_nodes(self, nodes, push_undo=True, prompt_warning=True): + """ + Extract select nodes from its connections. + + Args: + nodes (list[NodeGraphQt.BaseNode]): list of node instances. + push_undo (bool): register the command to the undo stack. (default: True) + prompt_warning (bool): prompt warning dialog box. + """ + if not nodes: + return + + locked_ports = [] + base_nodes = [] + for node in nodes: + if not isinstance(node, BaseNode): + continue + + for port in node.input_ports() + node.output_ports(): + if port.locked(): + locked_ports.append('{0.node.name}: {0.name}'.format(port)) + + base_nodes.append(node) + + if locked_ports: + message = ( + 'Selected nodes cannot be extracted because the following ' + 'ports are locked:\n{}'.format('\n'.join(sorted(locked_ports))) + ) + if prompt_warning: + self._viewer.message_dialog(message, 'Can\'t Extract Nodes') + return + + if push_undo: + self._undo_stack.beginMacro( + 'extracted "{}" node(s)'.format(len(nodes)) + ) + + for node in base_nodes: + for port in node.input_ports() + node.output_ports(): + for connected_port in port.connected_ports(): + if connected_port.node() in base_nodes: + continue + port.disconnect_from(connected_port, push_undo=push_undo) + + if push_undo: + self._undo_stack.endMacro() + + def all_nodes(self): + """ + Return all nodes in the node graph. + + Returns: + list[NodeGraphQt.BaseNode]: list of nodes. + """ + return list(self._model.nodes.values()) + + def selected_nodes(self): + """ + Return all selected nodes that are in the node graph. + + Returns: + list[NodeGraphQt.BaseNode]: list of nodes. + """ + nodes = [] + for item in self._viewer.selected_nodes(): + node = self._model.nodes[item.id] + nodes.append(node) + return nodes + + def select_all(self): + """ + Select all nodes in the node graph. + """ + self._undo_stack.beginMacro('select all') + [node.set_selected(True) for node in self.all_nodes()] + self._undo_stack.endMacro() + + def clear_selection(self): + """ + Clears the selection in the node graph. + """ + self._undo_stack.beginMacro('clear selection') + [node.set_selected(False) for node in self.all_nodes()] + self._undo_stack.endMacro() + + def invert_selection(self): + """ + Inverts the current node selection. + """ + if not self.selected_nodes(): + self.select_all() + return + self._undo_stack.beginMacro('invert selection') + for node in self.all_nodes(): + node.set_selected(not node.selected()) + self._undo_stack.endMacro() + + def get_node_by_id(self, node_id=None): + """ + Returns the node from the node id string. + + Args: + node_id (str): node id (:attr:`NodeObject.id`) + + Returns: + NodeGraphQt.NodeObject: node object. + """ + return self._model.nodes.get(node_id, None) + + def get_node_by_name(self, name): + """ + Returns node that matches the name. + + Args: + name (str): name of the node. + Returns: + NodeGraphQt.NodeObject: node object. + """ + for node_id, node in self._model.nodes.items(): + if node.name() == name: + return node + + def get_nodes_by_type(self, node_type): + """ + Return all nodes by their node type identifier. + (see: :attr:`NodeGraphQt.NodeObject.type_`) + + Args: + node_type (str): node type identifier. + + Returns: + list[NodeGraphQt.NodeObject]: list of nodes. + """ + return [n for n in self._model.nodes.values() if n.type_ == node_type] + + def get_unique_name(self, name): + """ + Creates a unique node name to avoid having nodes with the same name. + + Args: + name (str): node name. + + Returns: + str: unique node name. + """ + name = ' '.join(name.split()) + node_names = [n.name() for n in self.all_nodes()] + if name not in node_names: + return name + + regex = re.compile(r'\w+ (\d+)$') + search = regex.search(name) + if not search: + for x in range(1, len(node_names) + 2): + new_name = '{} {}'.format(name, x) + if new_name not in node_names: + return new_name + + version = search.group(1) + name = name[:len(version) * -1].strip() + for x in range(1, len(node_names) + 2): + new_name = '{} {}'.format(name, x) + if new_name not in node_names: + return new_name + + def current_session(self): + """ + Returns the file path to the currently loaded session. + + Returns: + str: path to the currently loaded session + """ + return self._model.session + + def clear_session(self): + """ + Clears the current node graph session. + """ + nodes = self.all_nodes() + for n in nodes: + if isinstance(n, BaseNode): + for p in n.input_ports(): + if p.locked(): + p.set_locked(False, connected_ports=False) + p.clear_connections() + for p in n.output_ports(): + if p.locked(): + p.set_locked(False, connected_ports=False) + p.clear_connections() + self._undo_stack.push(NodesRemovedCmd(self, nodes)) + self._undo_stack.clear() + self._model.session = '' + + def _serialize(self, nodes): + """ + serialize nodes to a dict. + (used internally by the node graph) + + Args: + nodes (list[NodeGraphQt.Nodes]): list of node instances. + + Returns: + dict: serialized data. + """ + serial_data = {'graph': {}, 'nodes': {}, 'connections': []} + nodes_data = {} + + # serialize graph session. + serial_data['graph']['layout_direction'] = self.layout_direction() + serial_data['graph']['acyclic'] = self.acyclic() + serial_data['graph']['pipe_collision'] = self.pipe_collision() + serial_data['graph']['pipe_slicing'] = self.pipe_slicing() + serial_data['graph']['pipe_style'] = self.pipe_style() + + # connection constrains. + serial_data['graph']['accept_connection_types'] = self.model.accept_connection_types + serial_data['graph']['reject_connection_types'] = self.model.reject_connection_types + + # serialize nodes. + for n in nodes: + # update the node model. + n.update_model() + + node_dict = n.model.to_dict + nodes_data.update(node_dict) + + for n_id, n_data in nodes_data.items(): + serial_data['nodes'][n_id] = n_data + + # serialize connections + inputs = n_data.pop('inputs') if n_data.get('inputs') else {} + outputs = n_data.pop('outputs') if n_data.get('outputs') else {} + + for pname, conn_data in inputs.items(): + for conn_id, prt_names in conn_data.items(): + for conn_prt in prt_names: + pipe = { + PortTypeEnum.IN.value: [n_id, pname], + PortTypeEnum.OUT.value: [conn_id, conn_prt] + } + if pipe not in serial_data['connections']: + serial_data['connections'].append(pipe) + + for pname, conn_data in outputs.items(): + for conn_id, prt_names in conn_data.items(): + for conn_prt in prt_names: + pipe = { + PortTypeEnum.OUT.value: [n_id, pname], + PortTypeEnum.IN.value: [conn_id, conn_prt] + } + if pipe not in serial_data['connections']: + serial_data['connections'].append(pipe) + + if not serial_data['connections']: + serial_data.pop('connections') + + return serial_data + + def _deserialize(self, data, relative_pos=False, pos=None): + """ + deserialize node data. + (used internally by the node graph) + + Args: + data (dict): node data. + relative_pos (bool): position node relative to the cursor. + pos (tuple or list): custom x, y position. + + Returns: + list[NodeGraphQt.Nodes]: list of node instances. + """ + # update node graph properties. + for attr_name, attr_value in data.get('graph', {}).items(): + if attr_name == 'layout_direction': + self.set_layout_direction(attr_value) + elif attr_name == 'acyclic': + self.set_acyclic(attr_value) + elif attr_name == 'pipe_collision': + self.set_pipe_collision(attr_value) + elif attr_name == 'pipe_slicing': + self.set_pipe_slicing(attr_value) + elif attr_name == 'pipe_style': + self.set_pipe_style(attr_value) + + # connection constrains. + elif attr_name == 'accept_connection_types': + self.model.accept_connection_types = attr_value + elif attr_name == 'reject_connection_types': + self.model.reject_connection_types = attr_value + + # build the nodes. + nodes = {} + for n_id, n_data in data.get('nodes', {}).items(): + identifier = n_data['type_'] + node = self._node_factory.create_node_instance(identifier) + if node: + node.NODE_NAME = n_data.get('name', node.NODE_NAME) + # set properties. + for prop in node.model.properties.keys(): + if prop in n_data.keys(): + node.model.set_property(prop, n_data[prop]) + # set custom properties. + for prop, val in n_data.get('custom', {}).items(): + node.model.set_property(prop, val) + if isinstance(node, BaseNode): + if prop in node.view.widgets: + node.view.widgets[prop].set_value(val) + + nodes[n_id] = node + self.add_node(node, n_data.get('pos')) + + if n_data.get('port_deletion_allowed', None): + node.set_ports({ + 'input_ports': n_data['input_ports'], + 'output_ports': n_data['output_ports'] + }) + + # build the connections. + for connection in data.get('connections', []): + nid, pname = connection.get('in', ('', '')) + in_node = nodes.get(nid) or self.get_node_by_id(nid) + if not in_node: + continue + in_port = in_node.inputs().get(pname) if in_node else None + + nid, pname = connection.get('out', ('', '')) + out_node = nodes.get(nid) or self.get_node_by_id(nid) + if not out_node: + continue + out_port = out_node.outputs().get(pname) if out_node else None + + if in_port and out_port: + # only connect if input port is not connected yet or input port + # can have multiple connections. + # important when duplicating nodes. + allow_connection = any([not in_port.model.connected_ports, + in_port.model.multi_connection]) + if allow_connection: + self._undo_stack.push( + PortConnectedCmd(in_port, out_port, emit_signal=False) + ) + + # Run on_input_connected to ensure connections are fully set up + # after deserialization. + in_node.on_input_connected(in_port, out_port) + + node_objs = nodes.values() + if relative_pos: + self._viewer.move_nodes([n.view for n in node_objs]) + [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] + elif pos: + self._viewer.move_nodes([n.view for n in node_objs], pos=pos) + [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] + + return node_objs + + def serialize_session(self): + """ + Serializes the current node graph layout to a dictionary. + + See Also: + :meth:`NodeGraph.deserialize_session`, + :meth:`NodeGraph.save_session`, + :meth:`NodeGraph.load_session` + + Returns: + dict: serialized session of the current node layout. + """ + return self._serialize(self.all_nodes()) + + def deserialize_session(self, layout_data, clear_session=True, + clear_undo_stack=True): + """ + Load node graph session from a dictionary object. + + See Also: + :meth:`NodeGraph.serialize_session`, + :meth:`NodeGraph.load_session`, + :meth:`NodeGraph.save_session` + + Args: + layout_data (dict): dictionary object containing a node session. + clear_session (bool): clear current session. + clear_undo_stack (bool): clear the undo stack. + """ + if clear_session: + self.clear_session() + self._deserialize(layout_data) + self.clear_selection() + if clear_undo_stack: + self._undo_stack.clear() + + def save_session(self, file_path): + """ + Saves the current node graph session layout to a `JSON` formatted file. + + See Also: + :meth:`NodeGraph.serialize_session`, + :meth:`NodeGraph.deserialize_session`, + :meth:`NodeGraph.load_session`, + + Args: + file_path (str): path to the saved node layout. + """ + serialized_data = self.serialize_session() + file_path = file_path.strip() + + def default(obj): + if isinstance(obj, set): + return list(obj) + return obj + + with open(file_path, 'w') as file_out: + json.dump( + serialized_data, + file_out, + indent=2, + separators=(',', ':'), + default=default + ) + + # update the current session. + self._model.session = file_path + + def load_session(self, file_path): + """ + Load node graph session layout file. + + See Also: + :meth:`NodeGraph.deserialize_session`, + :meth:`NodeGraph.serialize_session`, + :meth:`NodeGraph.save_session` + + Args: + file_path (str): path to the serialized layout file. + """ + file_path = file_path.strip() + if not os.path.isfile(file_path): + raise IOError('file does not exist: {}'.format(file_path)) + + self.clear_session() + self.import_session(file_path, clear_undo_stack=True) + + def import_session(self, file_path, clear_undo_stack=True): + """ + Import node graph into the current session. + + Args: + file_path (str): path to the serialized layout file. + clear_undo_stack (bool): clear the undo stack after import. + """ + file_path = file_path.strip() + if not os.path.isfile(file_path): + raise IOError('file does not exist: {}'.format(file_path)) + + try: + with open(file_path) as data_file: + layout_data = json.load(data_file) + except Exception as e: + layout_data = None + print('Cannot read data from file.\n{}'.format(e)) + + if not layout_data: + return + + self.deserialize_session( + layout_data, + clear_session=False, + clear_undo_stack=clear_undo_stack + ) + self._model.session = file_path + + self.session_changed.emit(file_path) + + def copy_nodes(self, nodes=None): + """ + Copy nodes to the clipboard as a JSON formatted ``str``. + + See Also: + :meth:`NodeGraph.cut_nodes` + + Args: + nodes (list[NodeGraphQt.BaseNode]): + list of nodes (default: selected nodes). + """ + nodes = nodes or self.selected_nodes() + if not nodes: + return False + clipboard = QtWidgets.QApplication.clipboard() + serial_data = self._serialize(nodes) + serial_str = json.dumps(serial_data) + if serial_str: + clipboard.setText(serial_str) + return True + return False + + def cut_nodes(self, nodes=None): + """ + Cut nodes to the clipboard as a JSON formatted ``str``. + + Note: + This function doesn't trigger the + :attr:`NodeGraph.nodes_deleted` signal. + + See Also: + :meth:`NodeGraph.copy_nodes` + + Args: + nodes (list[NodeGraphQt.BaseNode]): + list of nodes (default: selected nodes). + """ + nodes = nodes or self.selected_nodes() + self.copy_nodes(nodes) + self._undo_stack.beginMacro('cut nodes') + + for node in nodes: + if isinstance(node, BaseNode): + for p in node.input_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=True) + p.clear_connections() + for p in node.output_ports(): + if p.locked(): + p.set_locked(False, + connected_ports=False, + push_undo=True) + p.clear_connections() + + # collapse group node before removing. + if isinstance(node, GroupNode) and node.is_expanded: + node.collapse() + + self._undo_stack.push(NodesRemovedCmd(self, nodes)) + self._undo_stack.endMacro() + + def paste_nodes(self): + """ + Pastes nodes copied from the clipboard. + + Returns: + list[NodeGraphQt.BaseNode]: list of pasted node instances. + """ + clipboard = QtWidgets.QApplication.clipboard() + cb_text = clipboard.text() + if not cb_text: + return + + try: + serial_data = json.loads(cb_text) + except json.decoder.JSONDecodeError as e: + print('ERROR: Can\'t Decode Clipboard Data:\n' + '"{}"'.format(cb_text)) + return + + self._undo_stack.beginMacro('pasted nodes') + self.clear_selection() + nodes = self._deserialize(serial_data, relative_pos=True) + [n.set_selected(True) for n in nodes] + self._undo_stack.endMacro() + return nodes + + def duplicate_nodes(self, nodes): + """ + Create duplicate copy from the list of nodes. + + Args: + nodes (list[NodeGraphQt.BaseNode]): list of nodes. + Returns: + list[NodeGraphQt.BaseNode]: list of duplicated node instances. + """ + if not nodes: + return + + self._undo_stack.beginMacro('duplicate nodes') + + self.clear_selection() + serial = self._serialize(nodes) + new_nodes = self._deserialize(serial) + offset = 50 + for n in new_nodes: + x, y = n.pos() + n.set_pos(x + offset, y + offset) + n.set_property('selected', True) + + self._undo_stack.endMacro() + return new_nodes + + def disable_nodes(self, nodes, mode=None): + """ + Toggle nodes to be either disabled or enabled state. + + See Also: + :meth:`NodeObject.set_disabled` + + Args: + nodes (list[NodeGraphQt.BaseNode]): list of nodes. + mode (bool): (optional) override state of the nodes. + """ + if not nodes: + return + + if len(nodes) == 1: + if mode is None: + mode = not nodes[0].disabled() + nodes[0].set_disabled(mode) + return + + if mode is not None: + states = {False: 'enable', True: 'disable'} + text = '{} ({}) nodes'.format(states[mode], len(nodes)) + self._undo_stack.beginMacro(text) + [n.set_disabled(mode) for n in nodes] + self._undo_stack.endMacro() + return + + text = [] + enabled_count = len([n for n in nodes if n.disabled()]) + disabled_count = len([n for n in nodes if not n.disabled()]) + if enabled_count > 0: + text.append('enabled ({})'.format(enabled_count)) + if disabled_count > 0: + text.append('disabled ({})'.format(disabled_count)) + text = ' / '.join(text) + ' nodes' + + self._undo_stack.beginMacro(text) + [n.set_disabled(not n.disabled()) for n in nodes] + self._undo_stack.endMacro() + + def use_OpenGL(self): + """ + Set the viewport to use QOpenGLWidget widget to draw the graph. + """ + self._viewer.use_OpenGL() + + # auto layout node functions. + # -------------------------------------------------------------------------- + + @staticmethod + def _update_node_rank(node, nodes_rank, down_stream=True): + """ + Recursive function for updating the node ranking. + + Args: + node (NodeGraphQt.BaseNode): node to start from. + nodes_rank (dict): node ranking object to be updated. + down_stream (bool): true to rank down stram. + """ + if down_stream: + node_values = node.connected_output_nodes().values() + else: + node_values = node.connected_input_nodes().values() + + connected_nodes = set() + for nodes in node_values: + connected_nodes.update(nodes) + + rank = nodes_rank[node] + 1 + for n in connected_nodes: + if n in nodes_rank: + nodes_rank[n] = max(nodes_rank[n], rank) + else: + nodes_rank[n] = rank + NodeGraph._update_node_rank(n, nodes_rank, down_stream) + + @staticmethod + def _compute_node_rank(nodes, down_stream=True): + """ + Compute the ranking of nodes. + + Args: + nodes (list[NodeGraphQt.BaseNode]): nodes to start ranking from. + down_stream (bool): true to compute down stream. + + Returns: + dict: {NodeGraphQt.BaseNode: node_rank, ...} + """ + nodes_rank = {} + for node in nodes: + nodes_rank[node] = 0 + NodeGraph._update_node_rank(node, nodes_rank, down_stream) + return nodes_rank + + def auto_layout_nodes(self, nodes=None, down_stream=True, start_nodes=None): + """ + Auto layout the nodes in the node graph. + + Note: + If the node graph is acyclic then the ``start_nodes`` will need + to be specified. + + Args: + nodes (list[NodeGraphQt.BaseNode]): list of nodes to auto layout + if nodes is None then all nodes is layed out. + down_stream (bool): false to layout up stream. + start_nodes (list[NodeGraphQt.BaseNode]): + list of nodes to start the auto layout from (Optional). + """ + self.begin_undo('Auto Layout Nodes') + + nodes = nodes or self.all_nodes() + + # filter out the backdrops. + backdrops = { + n: n.nodes() for n in nodes if isinstance(n, BackdropNode) + } + filtered_nodes = [n for n in nodes if not isinstance(n, BackdropNode)] + + start_nodes = start_nodes or [] + if down_stream: + start_nodes += [ + n for n in filtered_nodes + if not any(n.connected_input_nodes().values()) + ] + else: + start_nodes += [ + n for n in filtered_nodes + if not any(n.connected_output_nodes().values()) + ] + + if not start_nodes: + return + + node_views = [n.view for n in nodes] + nodes_center_0 = self.viewer().nodes_rect_center(node_views) + + nodes_rank = NodeGraph._compute_node_rank(start_nodes, down_stream) + + rank_map = {} + for node, rank in nodes_rank.items(): + if rank in rank_map: + rank_map[rank].append(node) + else: + rank_map[rank] = [node] + + node_layout_direction = self._viewer.get_layout_direction() + + if node_layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + current_x = 0 + node_height = 120 + for rank in sorted(range(len(rank_map)), reverse=not down_stream): + ranked_nodes = rank_map[rank] + max_width = max([node.view.width for node in ranked_nodes]) + current_x += max_width + current_y = 0 + for idx, node in enumerate(ranked_nodes): + dy = max(node_height, node.view.height) + current_y += 0 if idx == 0 else dy + node.set_pos(current_x, current_y) + current_y += dy * 0.5 + 10 + + current_x += max_width * 0.5 + 100 + elif node_layout_direction is LayoutDirectionEnum.VERTICAL.value: + current_y = 0 + node_width = 250 + for rank in sorted(range(len(rank_map)), reverse=not down_stream): + ranked_nodes = rank_map[rank] + max_height = max([node.view.height for node in ranked_nodes]) + current_y += max_height + current_x = 0 + for idx, node in enumerate(ranked_nodes): + dx = max(node_width, node.view.width) + current_x += 0 if idx == 0 else dx + node.set_pos(current_x, current_y) + current_x += dx * 0.5 + 10 + + current_y += max_height * 0.5 + 100 + + nodes_center_1 = self.viewer().nodes_rect_center(node_views) + dx = nodes_center_0[0] - nodes_center_1[0] + dy = nodes_center_0[1] - nodes_center_1[1] + [n.set_pos(n.x_pos() + dx, n.y_pos() + dy) for n in nodes] + + # wrap the backdrop nodes. + for backdrop, contained_nodes in backdrops.items(): + backdrop.wrap_nodes(contained_nodes) + + self.end_undo() + + # convenience dialog functions. + # -------------------------------------------------------------------------- + + def question_dialog(self, text, title='Node Graph', dialog_icon=None, + custom_icon=None, parent=None): + """ + Prompts a question open dialog with ``"Yes"`` and ``"No"`` buttons in + the node graph. + + Note: + Convenience function to + :meth:`NodeGraph.viewer().question_dialog` + + Args: + text (str): question text. + title (str): dialog window title. + dialog_icon (str): display icon. ("information", "warning", "critical") + custom_icon (str): custom icon to display. + parent (QtWidgets.QObject): override dialog parent. (optional) + + Returns: + bool: true if user clicked yes. + """ + return self._viewer.question_dialog( + text, title, dialog_icon, custom_icon, parent + ) + + def message_dialog(self, text, title='Node Graph', dialog_icon=None, + custom_icon=None, parent=None): + """ + Prompts a file open dialog in the node graph. + + Note: + Convenience function to + :meth:`NodeGraph.viewer().message_dialog` + + Args: + text (str): message text. + title (str): dialog window title. + dialog_icon (str): display icon. ("information", "warning", "critical") + custom_icon (str): custom icon to display. + parent (QtWidgets.QObject): override dialog parent. (optional) + """ + self._viewer.message_dialog( + text, title, dialog_icon, custom_icon, parent + ) + + def load_dialog(self, current_dir=None, ext=None, parent=None): + """ + Prompts a file open dialog in the node graph. + + Note: + Convenience function to + :meth:`NodeGraph.viewer().load_dialog` + + Args: + current_dir (str): path to a directory. + ext (str): custom file type extension (default: ``"json"``) + parent (QtWidgets.QObject): override dialog parent. (optional) + + Returns: + str: selected file path. + """ + return self._viewer.load_dialog(current_dir, ext, parent) + + def save_dialog(self, current_dir=None, ext=None, parent=None): + """ + Prompts a file save dialog in the node graph. + + Note: + Convenience function to + :meth:`NodeGraph.viewer().save_dialog` + + Args: + current_dir (str): path to a directory. + ext (str): custom file type extension (default: ``"json"``) + parent (QtWidgets.QObject): override dialog parent. (optional) + + Returns: + str: selected file path. + """ + return self._viewer.save_dialog(current_dir, ext, parent) + + # group node / sub graph. + # -------------------------------------------------------------------------- + + def _on_close_sub_graph_tab(self, index): + """ + Called when the close button is clicked on a expanded sub graph tab. + + Args: + index (int): tab index. + """ + node_id = self.widget.tabToolTip(index) + group_node = self.get_node_by_id(node_id) + self.collapse_group_node(group_node) + + @property + def is_root(self): + """ + Returns if the node graph controller is the root graph. + + Returns: + bool: true is the node graph is root. + """ + return True + + @property + def sub_graphs(self): + """ + Returns expanded group node sub graphs. + + Returns: + dict: {: } + """ + return self._sub_graphs + + # def graph_rect(self): + # """ + # Get the graph viewer range (scene size). + # + # Returns: + # list[float]: [x, y, width, height]. + # """ + # return self._viewer.scene_rect() + # + # def set_graph_rect(self, rect): + # """ + # Set the graph viewer range (scene size). + # + # Args: + # rect (list[float]): [x, y, width, height]. + # """ + # self._viewer.set_scene_rect(rect) + + def expand_group_node(self, node): + """ + Expands a group node session in a new tab. + + Args: + node (NodeGraphQt.GroupNode): group node. + + Returns: + SubGraph: sub node graph used to manage the group node session. + """ + if not isinstance(node, GroupNode): + return + if self._widget is None: + raise RuntimeError('NodeGraph.widget not initialized!') + + self.viewer().clear_key_state() + self.viewer().clearFocus() + + if node.id in self._sub_graphs: + sub_graph = self._sub_graphs[node.id] + tab_index = self._widget.indexOf(sub_graph.widget) + self._widget.setCurrentIndex(tab_index) + return sub_graph + + # build new sub graph. + node_factory = copy.deepcopy(self.node_factory) + layout_direction = self.layout_direction() + kwargs = { + 'layout_direction': self.layout_direction(), + 'pipe_style': self.pipe_style(), + } + sub_graph = SubGraph(self, + node=node, + node_factory=node_factory, + **kwargs) + + # populate the sub graph. + session = node.get_sub_graph_session() + sub_graph.deserialize_session(session) + + # store reference to expanded. + self._sub_graphs[node.id] = sub_graph + + # open new tab at root level. + self.widget.add_viewer(sub_graph.widget, node.name(), node.id) + + return sub_graph + + def collapse_group_node(self, node): + """ + Collapse a group node session tab and it's expanded child sub graphs. + + Args: + node (NodeGraphQt.GroupNode): group node. + """ + assert isinstance(node, GroupNode), 'node must be a GroupNode instance.' + if self._widget is None: + return + + if node.id not in self._sub_graphs: + err = '{} sub graph not initialized!'.format(node.name()) + raise RuntimeError(err) + + sub_graph = self._sub_graphs.pop(node.id) + sub_graph.collapse_group_node(node) + + # remove the sub graph tab. + self.widget.remove_viewer(sub_graph.widget) + + # TODO: delete sub graph hmm... not sure if I need this here. + del sub_graph + + +class SubGraph(NodeGraph): + """ + The ``SubGraph`` class is just like the ``NodeGraph`` but is the main + controller for managing the expanded node graph for a + :class:`NodeGraphQt.GroupNode`. + + .. inheritance-diagram:: NodeGraphQt.SubGraph + :top-classes: PySide2.QtCore.QObject + + .. image:: ../_images/sub_graph.png + :width: 70% + + - + """ + + def __init__(self, parent=None, node=None, node_factory=None, **kwargs): + """ + Args: + parent (object): object parent. + node (GroupNode): group node related to this sub graph. + node_factory (NodeFactory): override node factory. + **kwargs (dict): additional kwargs. + """ + super(SubGraph, self).__init__( + parent, node_factory=node_factory, **kwargs + ) + + # sub graph attributes. + self._node = node + self._parent_graph = parent + self._subviewer_widget = None + + if self._parent_graph.is_root: + self._initialized_graphs = [self] + self._sub_graphs[self._node.id] = self + else: + # delete attributes if not top level sub graph. + del self._widget + del self._sub_graphs + + # clone context menu from the parent node graph. + self._clone_context_menu_from_parent() + + def __repr__(self): + return '<{}("{}") object at {}>'.format( + self.__class__.__name__, self._node.name(), hex(id(self))) + + def _register_builtin_nodes(self): + """ + Register the default builtin nodes to the :meth:`NodeGraph.node_factory` + """ + return + + def _clone_context_menu_from_parent(self): + """ + Clone the context menus from the parent node graph. + """ + graph_menu = self.get_context_menu('graph') + parent_menu = self.parent_graph.get_context_menu('graph') + parent_viewer = self.parent_graph.viewer() + excl_actions = [parent_viewer.qaction_for_undo(), + parent_viewer.qaction_for_redo()] + + def clone_menu(menu, menu_to_clone): + """ + Args: + menu (NodeGraphQt.NodeGraphMenu): + menu_to_clone (NodeGraphQt.NodeGraphMenu): + """ + sub_items = [] + for item in menu_to_clone.get_items(): + if item is None: + menu.add_separator() + continue + name = item.name() + if isinstance(item, NodeGraphMenu): + sub_menu = menu.add_menu(name) + sub_items.append([sub_menu, item]) + continue + + if item in excl_actions: + continue + + menu.add_command( + name, + func=item.slot_function, + shortcut=item.qaction.shortcut() + ) + + for sub_menu, to_clone in sub_items: + clone_menu(sub_menu, to_clone) + + # duplicate the menu items. + clone_menu(graph_menu, parent_menu) + + def _build_port_nodes(self): + """ + Build the corresponding input & output nodes from the parent node ports + and remove any port nodes that are outdated.. + + Returns: + tuple(dict, dict): input nodes, output nodes. + """ + node_layout_direction = self._viewer.get_layout_direction() + + # build the parent input port nodes. + input_nodes = {n.name(): n for n in + self.get_nodes_by_type(PortInputNode.type_)} + for port in self.node.input_ports(): + if port.name() not in input_nodes: + input_node = PortInputNode(parent_port=port) + input_node.NODE_NAME = port.name() + input_node.model.set_property('name', port.name()) + input_node.add_output(port.name()) + input_nodes[port.name()] = input_node + self.add_node(input_node, selected=False, push_undo=False) + x, y = input_node.pos() + if node_layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + x -= 100 + elif node_layout_direction is LayoutDirectionEnum.VERTICAL.value: + y -= 100 + input_node.set_property('pos', [x, y], push_undo=False) + + # build the parent output port nodes. + output_nodes = {n.name(): n for n in + self.get_nodes_by_type(PortOutputNode.type_)} + for port in self.node.output_ports(): + if port.name() not in output_nodes: + output_node = PortOutputNode(parent_port=port) + output_node.NODE_NAME = port.name() + output_node.model.set_property('name', port.name()) + output_node.add_input(port.name()) + output_nodes[port.name()] = output_node + self.add_node(output_node, selected=False, push_undo=False) + x, y = output_node.pos() + if node_layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + x += 100 + elif node_layout_direction is LayoutDirectionEnum.VERTICAL.value: + y += 100 + output_node.set_property('pos', [x, y], push_undo=False) + + return input_nodes, output_nodes + + def _deserialize(self, data, relative_pos=False, pos=None): + """ + deserialize node data. + (used internally by the node graph) + + Args: + data (dict): node data. + relative_pos (bool): position node relative to the cursor. + pos (tuple or list): custom x, y position. + + Returns: + list[NodeGraphQt.Nodes]: list of node instances. + """ + # update node graph properties. + for attr_name, attr_value in data.get('graph', {}).items(): + if attr_name == 'acyclic': + self.set_acyclic(attr_value) + elif attr_name == 'pipe_collision': + self.set_pipe_collision(attr_value) + + # build the port input & output nodes here. + input_nodes, output_nodes = self._build_port_nodes() + + # build the nodes. + nodes = {} + for n_id, n_data in data.get('nodes', {}).items(): + identifier = n_data['type_'] + name = n_data.get('name') + if identifier == PortInputNode.type_: + nodes[n_id] = input_nodes[name] + nodes[n_id].set_pos(*(n_data.get('pos') or [0, 0])) + continue + elif identifier == PortOutputNode.type_: + nodes[n_id] = output_nodes[name] + nodes[n_id].set_pos(*(n_data.get('pos') or [0, 0])) + continue + + node = self._node_factory.create_node_instance(identifier) + if not node: + continue + + node.NODE_NAME = name or node.NODE_NAME + # set properties. + for prop in node.model.properties.keys(): + if prop in n_data.keys(): + node.model.set_property(prop, n_data[prop]) + # set custom properties. + for prop, val in n_data.get('custom', {}).items(): + node.model.set_property(prop, val) + + nodes[n_id] = node + self.add_node(node, n_data.get('pos')) + + if n_data.get('port_deletion_allowed', None): + node.set_ports({ + 'input_ports': n_data['input_ports'], + 'output_ports': n_data['output_ports'] + }) + + # build the connections. + for connection in data.get('connections', []): + nid, pname = connection.get('in', ('', '')) + in_node = nodes.get(nid) + if not in_node: + continue + in_port = in_node.inputs().get(pname) if in_node else None + + nid, pname = connection.get('out', ('', '')) + out_node = nodes.get(nid) + if not out_node: + continue + out_port = out_node.outputs().get(pname) if out_node else None + + if in_port and out_port: + self._undo_stack.push( + PortConnectedCmd(in_port, out_port, emit_signal=False) + ) + + node_objs = list(nodes.values()) + if relative_pos: + self._viewer.move_nodes([n.view for n in node_objs]) + [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] + elif pos: + self._viewer.move_nodes([n.view for n in node_objs], pos=pos) + [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] + + return node_objs + + def _on_navigation_changed(self, node_id, rm_node_ids): + """ + Slot when the node navigation widget has changed. + + Args: + node_id (str): selected group node id. + rm_node_ids (list[str]): list of group node id to remove. + """ + # collapse child sub graphs. + for rm_node_id in rm_node_ids: + child_node = self.sub_graphs[rm_node_id].node + self.collapse_group_node(child_node) + + # show the selected node id sub graph. + sub_graph = self.sub_graphs.get(node_id) + if sub_graph: + self.widget.show_viewer(sub_graph.subviewer_widget) + sub_graph.viewer().setFocus() + + @property + def is_root(self): + """ + Returns if the node graph controller is the main root graph. + + Returns: + bool: true is the node graph is root. + """ + return False + + @property + def sub_graphs(self): + """ + Returns expanded group node sub graphs. + + Returns: + dict: {: } + """ + if self.parent_graph.is_root: + return self._sub_graphs + return self.parent_graph.sub_graphs + + @property + def initialized_graphs(self): + """ + Returns a list of the sub graphs in the order they were initialized. + + Returns: + list[NodeGraphQt.SubGraph]: list of sub graph objects. + """ + if self._parent_graph.is_root: + return self._initialized_graphs + return self._parent_graph.initialized_graphs + + @property + def widget(self): + """ + The sub graph widget from the top most sub graph. + + Returns: + SubGraphWidget: node graph widget. + """ + if self.parent_graph.is_root: + if self._widget is None: + self._widget = SubGraphWidget() + self._widget.add_viewer(self.subviewer_widget, + self.node.name(), + self.node.id) + # connect the navigator widget signals. + navigator = self._widget.navigator + navigator.navigation_changed.connect( + self._on_navigation_changed + ) + return self._widget + return self.parent_graph.widget + + @property + def navigation_widget(self): + """ + The navigation widget from the top most sub graph. + + Returns: + NodeNavigationWidget: navigation widget. + """ + if self.parent_graph.is_root: + return self.widget.navigator + return self.parent_graph.navigation_widget + + @property + def subviewer_widget(self): + """ + The widget to the sub graph. + + Returns: + PySide2.QtWidgets.QWidget: node graph widget. + """ + if self._subviewer_widget is None: + self._subviewer_widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(self._subviewer_widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(1) + layout.addWidget(self._viewer) + return self._subviewer_widget + + @property + def parent_graph(self): + """ + The parent node graph controller. + + Returns: + NodeGraphQt.NodeGraph or NodeGraphQt.SubGraph: parent graph. + """ + return self._parent_graph + + @property + def node(self): + """ + Returns the parent node to the sub graph. + + .. image:: ../_images/group_node.png + :width: 250px + + Returns: + NodeGraphQt.GroupNode: group node. + """ + return self._node + + def delete_node(self, node, push_undo=True): + """ + Remove the node from the node sub graph. + + Note: + :class:`.PortInputNode` & :class:`.PortOutputNode` can't be deleted + as they are connected to a :class:`.Port` to remove these port nodes + see :meth:`BaseNode.delete_input`, :meth:`BaseNode.delete_output`. + + Args: + node (NodeGraphQt.BaseNode): node object. + push_undo (bool): register the command to the undo stack. (default: True) + """ + port_nodes = self.get_input_port_nodes() + self.get_output_port_nodes() + if node in port_nodes and node.parent_port is not None: + # note: port nodes can only be deleted by deleting the parent + # port object. + raise NodeDeletionError( + '{} can\'t be deleted as it is attached to a port!'.format(node) + ) + super(SubGraph, self).delete_node(node, push_undo=push_undo) + + def delete_nodes(self, nodes, push_undo=True): + """ + Remove a list of specified nodes from the node graph. + + Args: + nodes (list[NodeGraphQt.BaseNode]): list of node instances. + push_undo (bool): register the command to the undo stack. (default: True) + """ + if not nodes: + return + + port_nodes = self.get_input_port_nodes() + self.get_output_port_nodes() + for node in nodes: + if node in port_nodes and node.parent_port is not None: + # note: port nodes can only be deleted by deleting the parent + # port object. + raise NodeDeletionError( + '{} can\'t be deleted as it is attached to a port!' + .format(node) + ) + + super(SubGraph, self).delete_nodes(nodes, push_undo=push_undo) + + def collapse_graph(self, clear_session=True): + """ + Collapse the current sub graph and hide its widget. + + Args: + clear_session (bool): clear the current session. + """ + # update the group node. + serialized_session = self.serialize_session() + self.node.set_sub_graph_session(serialized_session) + + # close the visible widgets. + if self._undo_view: + self._undo_view.close() + + if self._subviewer_widget: + self.widget.hide_viewer(self._subviewer_widget) + + if clear_session: + self.clear_session() + + def expand_group_node(self, node): + """ + Expands a group node session in current sub view. + + Args: + node (NodeGraphQt.GroupNode): group node. + + Returns: + SubGraph: sub node graph used to manage the group node session. + """ + assert isinstance(node, GroupNode), 'node must be a GroupNode instance.' + if self._subviewer_widget is None: + raise RuntimeError('SubGraph.widget not initialized!') + + self.viewer().clear_key_state() + self.viewer().clearFocus() + + if node.id in self.sub_graphs: + sub_graph_viewer = self.sub_graphs[node.id].viewer() + sub_graph_viewer.setFocus() + return self.sub_graphs[node.id] + + # collapse expanded child sub graphs. + group_ids = [n.id for n in self.all_nodes() if isinstance(n, GroupNode)] + for grp_node_id, grp_sub_graph in self.sub_graphs.items(): + # collapse current group node. + if grp_node_id in group_ids: + grp_node = self.get_node_by_id(grp_node_id) + self.collapse_group_node(grp_node) + + # close the widgets + grp_sub_graph.collapse_graph(clear_session=False) + + # build new sub graph. + node_factory = copy.deepcopy(self.node_factory) + sub_graph = SubGraph(self, + node=node, + node_factory=node_factory, + layout_direction=self.layout_direction()) + + # populate the sub graph. + serialized_session = node.get_sub_graph_session() + sub_graph.deserialize_session(serialized_session) + + # open new sub graph view. + self.widget.add_viewer(sub_graph.subviewer_widget, + node.name(), + node.id) + + # store the references. + self.sub_graphs[node.id] = sub_graph + self.initialized_graphs.append(sub_graph) + + return sub_graph + + def collapse_group_node(self, node): + """ + Collapse a group node session and it's expanded child sub graphs. + + Args: + node (NodeGraphQt.GroupNode): group node. + """ + # update the references. + sub_graph = self.sub_graphs.pop(node.id, None) + if not sub_graph: + return + + init_idx = self.initialized_graphs.index(sub_graph) + 1 + for sgraph in reversed(self.initialized_graphs[init_idx:]): + self.initialized_graphs.remove(sgraph) + + # collapse child sub graphs here. + child_ids = [ + n.id for n in sub_graph.all_nodes() if isinstance(n, GroupNode) + ] + for child_id in child_ids: + if self.sub_graphs.get(child_id): + child_graph = self.sub_graphs.pop(child_id) + child_graph.collapse_graph(clear_session=True) + # remove child viewer widget. + self.widget.remove_viewer(child_graph.subviewer_widget) + + sub_graph.collapse_graph(clear_session=True) + self.widget.remove_viewer(sub_graph.subviewer_widget) + + def get_input_port_nodes(self): + """ + Return all the port nodes related to the group node input ports. + + .. image:: ../_images/port_in_node.png + :width: 150px + + - + + See Also: + :meth:`NodeGraph.get_nodes_by_type`, + :meth:`SubGraph.get_output_port_nodes` + + Returns: + list[NodeGraphQt.PortInputNode]: input nodes. + """ + return self.get_nodes_by_type(PortInputNode.type_) + + def get_output_port_nodes(self): + """ + Return all the port nodes related to the group node output ports. + + .. image:: ../_images/port_out_node.png + :width: 150px + + - + + See Also: + :meth:`NodeGraph.get_nodes_by_type`, + :meth:`SubGraph.get_input_port_nodes` + + Returns: + list[NodeGraphQt.PortOutputNode]: output nodes. + """ + return self.get_nodes_by_type(PortOutputNode.type_) + + def get_node_by_port(self, port): + """ + Returns the node related to the parent group node port object. + + Args: + port (NodeGraphQt.Port): parent node port object. + + Returns: + PortInputNode or PortOutputNode: port node object. + """ + func_type = { + PortTypeEnum.IN.value: self.get_input_port_nodes, + PortTypeEnum.OUT.value: self.get_output_port_nodes + } + for n in func_type.get(port.type_(), []): + if port == n.parent_port: + return n diff --git a/cuegui/NodeGraphQt/base/menu.py b/cuegui/NodeGraphQt/base/menu.py new file mode 100644 index 000000000..8270e9712 --- /dev/null +++ b/cuegui/NodeGraphQt/base/menu.py @@ -0,0 +1,335 @@ +#!/usr/bin/python +import re +from distutils.version import LooseVersion + +from qtpy import QtGui, QtCore + +from NodeGraphQt.errors import NodeMenuError +from NodeGraphQt.widgets.actions import BaseMenu, GraphAction, NodeAction + + +class NodeGraphMenu(object): + """ + The ``NodeGraphMenu`` is the main context menu triggered from the node graph. + + .. inheritance-diagram:: NodeGraphQt.NodeGraphMenu + :parts: 1 + + example for accessing the node graph context menu. + + .. code-block:: python + :linenos: + + from NodeGraphQt import NodeGraph + + node_graph = NodeGraph() + + # get the context menu for the node graph. + context_menu = node_graph.get_context_menu('graph') + + """ + + def __init__(self, graph, qmenu): + self._graph = graph + self._qmenu = qmenu + self._name = qmenu.title() + self._menus = {} + self._commands = {} + self._items = [] + + def __repr__(self): + return '<{}("{}") object at {}>'.format( + self.__class__.__name__, self.name(), hex(id(self))) + + @property + def qmenu(self): + """ + The underlying QMenu. + + Returns: + BaseMenu: menu object. + """ + return self._qmenu + + def name(self): + """ + Returns the name for the menu. + + Returns: + str: label name. + """ + return self._name + + def get_items(self): + """ + Return the menu items in the order they were added. + + Returns: + list: current menu items. + """ + return self._items + + def get_menu(self, name): + """ + Returns the child menu by name. + + Args: + name (str): name of the menu. + + Returns: + NodeGraphQt.NodeGraphMenu: menu item. + """ + self._menus.get(name) + + def get_command(self, name): + """ + Returns the child menu command by name. + + Args: + name (str): name of the command. + + Returns: + NodeGraphQt.NodeGraphCommand: context menu command. + """ + return self._commands.get(name) + + def add_menu(self, name): + """ + Adds a child menu to the current menu. + + Args: + name (str): menu name. + + Returns: + NodeGraphQt.NodeGraphMenu: the appended menu item. + """ + if name in self._menus: + raise NodeMenuError('menu object "{}" already exists!'.format(name)) + base_menu = BaseMenu(name, self.qmenu) + self.qmenu.addMenu(base_menu) + menu = NodeGraphMenu(self._graph, base_menu) + self._menus[name] = menu + self._items.append(menu) + return menu + + @staticmethod + def _set_shortcut(action, shortcut): + if isinstance(shortcut, str): + search = re.search(r'(?:\.|)QKeySequence\.(\w+)', shortcut) + if search: + shortcut = getattr(QtGui.QKeySequence, search.group(1)) + elif all([i in ['Alt', 'Enter'] for i in shortcut.split('+')]): + shortcut = QtGui.QKeySequence( + QtCore.Qt.Modifier.ALT | QtCore.Qt.Key.Key_Return + ) + elif all([i in ['Return', 'Enter'] for i in shortcut.split('+')]): + shortcut = QtCore.Qt.Key.Key_Return + if shortcut: + action.setShortcut(shortcut) + + def add_command(self, name, func=None, shortcut=None): + """ + Adds a command to the menu. + + Args: + name (str): command name. + func (function): command function eg. "func(``graph``)". + shortcut (str): shortcut key. + + Returns: + NodeGraphQt.NodeGraphCommand: the appended command. + """ + action = GraphAction(name, self._graph.viewer()) + action.graph = self._graph + if LooseVersion(QtCore.qVersion()) >= LooseVersion('5.10'): + action.setShortcutVisibleInContextMenu(True) + + if shortcut: + self._set_shortcut(action, shortcut) + if func: + action.executed.connect(func) + self.qmenu.addAction(action) + command = NodeGraphCommand(self._graph, action, func) + self._commands[name] = command + self._items.append(command) + return command + + def add_separator(self): + """ + Adds a separator to the menu. + """ + self.qmenu.addSeparator() + self._items.append(None) + + +class NodesMenu(NodeGraphMenu): + """ + The ``NodesMenu`` is the context menu triggered from a node. + + .. inheritance-diagram:: NodeGraphQt.NodesMenu + :parts: 1 + + example for accessing the nodes context menu. + + .. code-block:: python + :linenos: + + from NodeGraphQt import NodeGraph + + node_graph = NodeGraph() + + # get the nodes context menu. + nodes_menu = node_graph.get_context_menu('nodes') + """ + + def add_command(self, name, func=None, node_type=None, node_class=None, + shortcut=None): + """ + Re-implemented to add a command to the specified node type menu. + + Args: + name (str): command name. + func (function): command function eg. "func(``graph``, ``node``)". + node_type (str): specified node type for the command. + node_class (class): specified node class for the command. + shortcut (str): shortcut key. + + Returns: + NodeGraphQt.NodeGraphCommand: the appended command. + """ + if not node_type and not node_class: + raise NodeMenuError('Node type or Node class not specified!') + + if node_class: + node_type = node_class.__name__ + + node_menu = self.qmenu.get_menu(node_type) + if not node_menu: + node_menu = BaseMenu(node_type, self.qmenu) + + if node_class: + node_menu.node_class = node_class + node_menu.graph = self._graph + + self.qmenu.addMenu(node_menu) + + if not self.qmenu.isEnabled(): + self.qmenu.setDisabled(False) + + action = NodeAction(name, self._graph.viewer()) + action.graph = self._graph + if LooseVersion(QtCore.qVersion()) >= LooseVersion('5.10'): + action.setShortcutVisibleInContextMenu(True) + + if shortcut: + self._set_shortcut(action, shortcut) + if func: + action.executed.connect(func) + + if node_class: + node_menus = self.qmenu.get_menus(node_class) + if node_menu in node_menus: + node_menus.remove(node_menu) + for menu in node_menus: + menu.addAction(action) + + node_menu.addAction(action) + command = NodeGraphCommand(self._graph, action, func) + self._commands[name] = command + self._items.append(command) + return command + + +class NodeGraphCommand(object): + """ + Node graph menu command. + + .. inheritance-diagram:: NodeGraphQt.NodeGraphCommand + :parts: 1 + + """ + + def __init__(self, graph, qaction, func=None): + self._graph = graph + self._qaction = qaction + self._name = qaction.text() + self._func = func + + def __repr__(self): + return '<{}("{}") object at {}>'.format( + self.__class__.__name__, self.name(), hex(id(self))) + + @property + def qaction(self): + """ + The underlying qaction. + + Returns: + GraphAction: qaction object. + """ + return self._qaction + + @property + def slot_function(self): + """ + The function executed by this command. + + Returns: + function: command function. + """ + return self._func + + def name(self): + """ + Returns the name for the menu command. + + Returns: + str: label name. + """ + return self._name + + def set_shortcut(self, shortcut=None): + """ + Sets the shortcut key combination for the menu command. + + Args: + shortcut (str): shortcut key. + """ + shortcut = shortcut or QtGui.QKeySequence() + self.qaction.setShortcut(shortcut) + + def run_command(self): + """ + execute the menu command. + """ + self.qaction.trigger() + + def set_enabled(self, state): + """ + Sets the command to either be enabled or disabled. + + Args: + state (bool): true to enable. + """ + self.qaction.setEnabled(state) + + def set_hidden(self, hidden): + """ + Sets then command item visibility in the context menu. + + Args: + hidden (bool): true to hide the command item. + """ + self.qaction.setVisible(not hidden) + + def show(self): + """ + Set the command to be visible in the context menu. + """ + self.qaction.setVisible(True) + + def hide(self): + """ + Set the command to be hidden in the context menu. + """ + self.qaction.setVisible(False) diff --git a/cuegui/NodeGraphQt/base/model.py b/cuegui/NodeGraphQt/base/model.py new file mode 100644 index 000000000..83c4e948d --- /dev/null +++ b/cuegui/NodeGraphQt/base/model.py @@ -0,0 +1,627 @@ +#!/usr/bin/python +import json +from collections import defaultdict + +from NodeGraphQt.constants import ( + LayoutDirectionEnum, + NodePropWidgetEnum, + PipeLayoutEnum +) +from NodeGraphQt.errors import NodePropertyError + + +class PortModel(object): + """ + Data dump for a port object. + """ + + def __init__(self, node): + self.node = node + self.type_ = '' + self.name = 'port' + self.display_name = True + self.multi_connection = False + self.visible = True + self.locked = False + self.connected_ports = defaultdict(list) + + def __repr__(self): + return '<{}(\'{}\') object at {}>'.format( + self.__class__.__name__, self.name, hex(id(self))) + + @property + def to_dict(self): + """ + serialize model information to a dictionary. + + Returns: + dict: node port dictionary eg. + { + 'type': 'in', + 'name': 'port', + 'display_name': True, + 'multi_connection': False, + 'visible': True, + 'locked': False, + 'connected_ports': {: [, ]} + } + """ + props = self.__dict__.copy() + props.pop('node') + props['connected_ports'] = dict(props.pop('connected_ports')) + return props + + +class NodeModel(object): + """ + Data dump for a node object. + """ + + def __init__(self): + self.type_ = None + self.id = hex(id(self)) + self.icon = None + self.name = 'node' + self.color = (13, 18, 23, 255) + self.border_color = (74, 84, 85, 255) + self.text_color = (255, 255, 255, 180) + self.disabled = False + self.selected = False + self.visible = True + self.width = 100.0 + self.height = 80.0 + self.pos = [0.0, 0.0] + self.layout_direction = LayoutDirectionEnum.HORIZONTAL.value + + # BaseNode attrs. + self.inputs = {} + self.outputs = {} + self.port_deletion_allowed = False + + # GroupNode attrs. + self.subgraph_session = {} + + # Custom + self._custom_prop = {} + + # node graph model set at node added time. + self._graph_model = None + + # store the property attributes. + # (deleted when node is added to the graph) + self._TEMP_property_attrs = {} + + # temp store the property widget types. + # (deleted when node is added to the graph) + self._TEMP_property_widget_types = { + 'type_': NodePropWidgetEnum.QLABEL.value, + 'id': NodePropWidgetEnum.QLABEL.value, + 'icon': NodePropWidgetEnum.HIDDEN.value, + 'name': NodePropWidgetEnum.QLINE_EDIT.value, + 'color': NodePropWidgetEnum.COLOR_PICKER.value, + 'border_color': NodePropWidgetEnum.COLOR_PICKER.value, + 'text_color': NodePropWidgetEnum.COLOR_PICKER.value, + 'disabled': NodePropWidgetEnum.QCHECK_BOX.value, + 'selected': NodePropWidgetEnum.HIDDEN.value, + 'width': NodePropWidgetEnum.HIDDEN.value, + 'height': NodePropWidgetEnum.HIDDEN.value, + 'pos': NodePropWidgetEnum.HIDDEN.value, + 'layout_direction': NodePropWidgetEnum.HIDDEN.value, + 'inputs': NodePropWidgetEnum.HIDDEN.value, + 'outputs': NodePropWidgetEnum.HIDDEN.value, + } + + # temp store connection constrains. + # (deleted when node is added to the graph) + self._TEMP_accept_connection_types = {} + self._TEMP_reject_connection_types = {} + + def __repr__(self): + return '<{}(\'{}\') object at {}>'.format( + self.__class__.__name__, self.name, self.id) + + def add_property(self, name, value, items=None, range=None, + widget_type=None, widget_tooltip=None, tab=None): + """ + add custom property or raises an error if the property name is already + taken. + + Args: + name (str): name of the property. + value (object): data. + items (list[str]): items used by widget type NODE_PROP_QCOMBO. + range (tuple): min, max values used by NODE_PROP_SLIDER. + widget_type (int): widget type flag. + widget_tooltip (str): custom tooltip for the property widget. + tab (str): widget tab name. + """ + widget_type = widget_type or NodePropWidgetEnum.HIDDEN.value + tab = tab or 'Properties' + + if name in self.properties.keys(): + raise NodePropertyError( + '"{}" reserved for default property.'.format(name)) + if name in self._custom_prop.keys(): + raise NodePropertyError( + '"{}" property already exists.'.format(name)) + + self._custom_prop[name] = value + + if self._graph_model is None: + self._TEMP_property_widget_types[name] = widget_type + self._TEMP_property_attrs[name] = {'tab': tab} + if items: + self._TEMP_property_attrs[name]['items'] = items + if range: + self._TEMP_property_attrs[name]['range'] = range + if widget_tooltip: + self._TEMP_property_attrs[name]['tooltip'] = widget_tooltip + + else: + attrs = { + self.type_: { + name: { + 'widget_type': widget_type, + 'tab': tab + } + } + } + if items: + attrs[self.type_][name]['items'] = items + if range: + attrs[self.type_][name]['range'] = range + if widget_tooltip: + attrs[self.type_][name]['tooltip'] = widget_tooltip + self._graph_model.set_node_common_properties(attrs) + + def set_property(self, name, value): + """ + Args: + name (str): property name. + value (object): property value. + """ + if name in self.properties.keys(): + setattr(self, name, value) + elif name in self._custom_prop.keys(): + self._custom_prop[name] = value + else: + raise NodePropertyError('No property "{}"'.format(name)) + + def get_property(self, name): + """ + Args: + name (str): property name. + + Returns: + object: property value. + """ + if name in self.properties.keys(): + return self.properties[name] + return self._custom_prop.get(name) + + def is_custom_property(self, name): + """ + Args: + name (str): property name. + + Returns: + bool: true if custom property. + """ + return name in self._custom_prop + + def get_widget_type(self, name): + """ + Args: + name (str): property name. + + Returns: + int: node property widget type. + """ + model = self._graph_model + if model is None: + return self._TEMP_property_widget_types.get(name) + return model.get_node_common_properties(self.type_)[name]['widget_type'] + + def get_tab_name(self, name): + """ + Args: + name (str): property name. + + Returns: + str: name of the tab for the properties bin. + """ + model = self._graph_model + if model is None: + attrs = self._TEMP_property_attrs.get(name) + if attrs: + return attrs[name].get('tab') + return + return model.get_node_common_properties(self.type_)[name]['tab'] + + def add_port_accept_connection_type( + self, + port_name, port_type, node_type, + accept_pname, accept_ptype, accept_ntype + ): + """ + Convenience function for adding to the "accept_connection_types" dict. + If the node graph model is unavailable yet then we store it to a + temp var that gets deleted. + + Args: + port_name (str): current port name. + port_type (str): current port type. + node_type (str): current port node type. + accept_pname (str):port name to accept. + accept_ptype (str): port type accept. + accept_ntype (str):port node type to accept. + """ + model = self._graph_model + if model: + model.add_port_accept_connection_type( + port_name, port_type, node_type, + accept_pname, accept_ptype, accept_ntype + ) + return + + connection_data = self._TEMP_accept_connection_types + keys = [node_type, port_type, port_name, accept_ntype] + for key in keys: + if key not in connection_data.keys(): + connection_data[key] = {} + connection_data = connection_data[key] + + if accept_ptype not in connection_data: + connection_data[accept_ptype] = set([accept_pname]) + else: + connection_data[accept_ptype].add(accept_pname) + + def add_port_reject_connection_type( + self, + port_name, port_type, node_type, + reject_pname, reject_ptype, reject_ntype + ): + """ + Convenience function for adding to the "reject_connection_types" dict. + If the node graph model is unavailable yet then we store it to a + temp var that gets deleted. + + Args: + port_name (str): current port name. + port_type (str): current port type. + node_type (str): current port node type. + reject_pname: + reject_ptype: + reject_ntype: + + Returns: + + """ + model = self._graph_model + if model: + model.add_port_reject_connection_type( + port_name, port_type, node_type, + reject_pname, reject_ptype, reject_ntype + ) + return + + connection_data = self._TEMP_reject_connection_types + keys = [node_type, port_type, port_name, reject_ntype] + for key in keys: + if key not in connection_data.keys(): + connection_data[key] = {} + connection_data = connection_data[key] + + if reject_ptype not in connection_data: + connection_data[reject_ptype] = set([reject_pname]) + else: + connection_data[reject_ptype].add(reject_pname) + + @property + def properties(self): + """ + return all default node properties. + + Returns: + dict: default node properties. + """ + props = self.__dict__.copy() + exclude = ['_custom_prop', + '_graph_model', + '_TEMP_property_attrs', + '_TEMP_property_widget_types'] + [props.pop(i) for i in exclude if i in props.keys()] + return props + + @property + def custom_properties(self): + """ + return all custom properties specified by the user. + + Returns: + dict: user defined properties. + """ + return self._custom_prop + + @property + def to_dict(self): + """ + serialize model information to a dictionary. + + Returns: + dict: node id as the key and properties as the values eg. + {'0x106cf75a8': { + 'name': 'foo node', + 'color': (48, 58, 69, 255), + 'border_color': (85, 100, 100, 255), + 'text_color': (255, 255, 255, 180), + 'type_': 'io.github.jchanvfx.FooNode', + 'selected': False, + 'disabled': False, + 'visible': True, + 'width': 0.0, + 'height: 0.0, + 'pos': (0.0, 0.0), + 'layout_direction': 0, + 'custom': {}, + 'inputs': { + : {: [, ]} + }, + 'outputs': { + : {: [, ]} + }, + 'input_ports': [, ], + 'output_ports': [, ], + }, + subgraph_session: + } + """ + node_dict = self.__dict__.copy() + node_id = node_dict.pop('id') + + inputs = {} + outputs = {} + input_ports = [] + output_ports = [] + for name, model in node_dict.pop('inputs').items(): + if self.port_deletion_allowed: + input_ports.append({ + 'name': name, + 'multi_connection': model.multi_connection, + 'display_name': model.display_name, + }) + connected_ports = model.to_dict['connected_ports'] + if connected_ports: + inputs[name] = connected_ports + for name, model in node_dict.pop('outputs').items(): + if self.port_deletion_allowed: + output_ports.append({ + 'name': name, + 'multi_connection': model.multi_connection, + 'display_name': model.display_name, + }) + connected_ports = model.to_dict['connected_ports'] + if connected_ports: + outputs[name] = connected_ports + if inputs: + node_dict['inputs'] = inputs + if outputs: + node_dict['outputs'] = outputs + + if self.port_deletion_allowed: + node_dict['input_ports'] = input_ports + node_dict['output_ports'] = output_ports + + if self.subgraph_session: + node_dict['subgraph_session'] = self.subgraph_session + + custom_props = node_dict.pop('_custom_prop', {}) + if custom_props: + node_dict['custom'] = custom_props + + exclude = ['_graph_model', + '_TEMP_property_attrs', + '_TEMP_property_widget_types'] + [node_dict.pop(i) for i in exclude if i in node_dict.keys()] + + return {node_id: node_dict} + + @property + def serial(self): + """ + Serialize model information to a string. + + Returns: + str: serialized JSON string. + """ + model_dict = self.to_dict + return json.dumps(model_dict) + + +class NodeGraphModel(object): + """ + Data dump for a node graph. + """ + + def __init__(self): + self.__common_node_props = {} + + self.accept_connection_types = {} + self.reject_connection_types = {} + + self.nodes = {} + self.session = '' + self.acyclic = True + self.pipe_collision = False + self.pipe_slicing = True + self.pipe_style = PipeLayoutEnum.CURVED.value + self.layout_direction = LayoutDirectionEnum.HORIZONTAL.value + + def common_properties(self): + """ + Return all common node properties. + + Returns: + dict: common node properties. + eg. + {'nodeGraphQt.nodes.FooNode': { + 'my_property': { + 'widget_type': 0, + 'tab': 'Properties', + 'items': ['foo', 'bar', 'test'], + 'range': (0, 100) + } + } + } + """ + return self.__common_node_props + + def set_node_common_properties(self, attrs): + """ + Store common node properties. + + Args: + attrs (dict): common node properties. + eg. + {'nodeGraphQt.nodes.FooNode': { + 'my_property': { + 'widget_type': 0, + 'tab': 'Properties', + 'items': ['foo', 'bar', 'test'], + 'range': (0, 100) + } + } + } + """ + for node_type in attrs.keys(): + node_props = attrs[node_type] + + if node_type not in self.__common_node_props.keys(): + self.__common_node_props[node_type] = node_props + continue + + for prop_name, prop_attrs in node_props.items(): + common_props = self.__common_node_props[node_type] + if prop_name not in common_props.keys(): + common_props[prop_name] = prop_attrs + continue + common_props[prop_name].update(prop_attrs) + + def get_node_common_properties(self, node_type): + """ + Return all the common properties for a registered node. + + Args: + node_type (str): node type. + + Returns: + dict: node common properties. + """ + return self.__common_node_props.get(node_type) + + def add_port_accept_connection_type( + self, + port_name, port_type, node_type, + accept_pname, accept_ptype, accept_ntype + ): + """ + Convenience function for adding to the "accept_connection_types" dict. + + Args: + port_name (str): current port name. + port_type (str): current port type. + node_type (str): current port node type. + accept_pname (str):port name to accept. + accept_ptype (str): port type accept. + accept_ntype (str):port node type to accept. + """ + connection_data = self.accept_connection_types + keys = [node_type, port_type, port_name, accept_ntype] + for key in keys: + if key not in connection_data.keys(): + connection_data[key] = {} + connection_data = connection_data[key] + + if accept_ptype not in connection_data: + connection_data[accept_ptype] = [accept_pname] + else: + connection_data[accept_ptype].append(accept_pname) + + def port_accept_connection_types(self, node_type, port_type, port_name): + """ + Convenience function for getting the accepted port types from the + "accept_connection_types" dict. + + Args: + node_type (str): + port_type (str): + port_name (str): + + Returns: + dict: {: {: []}} + """ + data = self.accept_connection_types.get(node_type) or {} + accepted_types = data.get(port_type) or {} + return accepted_types.get(port_name) or {} + + def add_port_reject_connection_type( + self, + port_name, port_type, node_type, + reject_pname, reject_ptype, reject_ntype + ): + """ + Convenience function for adding to the "reject_connection_types" dict. + + Args: + port_name (str): current port name. + port_type (str): current port type. + node_type (str): current port node type. + reject_pname (str): port name to reject. + reject_ptype (str): port type to reject. + reject_ntype (str): port node type to reject. + """ + connection_data = self.reject_connection_types + keys = [node_type, port_type, port_name, reject_ntype] + for key in keys: + if key not in connection_data.keys(): + connection_data[key] = {} + connection_data = connection_data[key] + + if reject_ptype not in connection_data: + connection_data[reject_ptype] = [reject_pname] + else: + connection_data[reject_ptype].append(reject_pname) + + def port_reject_connection_types(self, node_type, port_type, port_name): + """ + Convenience function for getting the accepted port types from the + "reject_connection_types" dict. + + Args: + node_type (str): + port_type (str): + port_name (str): + + Returns: + dict: {: {: []}} + """ + data = self.reject_connection_types.get(node_type) or {} + rejected_types = data.get(port_type) or {} + return rejected_types.get(port_name) or {} + + +if __name__ == '__main__': + p = PortModel(None) + # print(p.to_dict) + + n = NodeModel() + n.inputs[p.name] = p + n.add_property('foo', 'bar') + + print('-'*100) + print('property keys\n') + print(list(n.properties.keys())) + print('-'*100) + print('to_dict\n') + for k, v in n.to_dict[n.id].items(): + print(k, v) diff --git a/cuegui/NodeGraphQt/base/node.py b/cuegui/NodeGraphQt/base/node.py new file mode 100644 index 000000000..9a5593b0e --- /dev/null +++ b/cuegui/NodeGraphQt/base/node.py @@ -0,0 +1,529 @@ +#!/usr/bin/python +from NodeGraphQt.base.commands import PropertyChangedCmd +from NodeGraphQt.base.model import NodeModel +from NodeGraphQt.constants import NodePropWidgetEnum + + +class _ClassProperty(object): + + def __init__(self, f): + self.f = f + + def __get__(self, instance, owner): + return self.f(owner) + + +class NodeObject(object): + """ + The ``NodeGraphQt.NodeObject`` class is the main base class that all + nodes inherit from. + + .. inheritance-diagram:: NodeGraphQt.NodeObject + + Args: + qgraphics_item (AbstractNodeItem): QGraphicsItem item used for drawing. + """ + + __identifier__ = 'nodeGraphQt.nodes' + """ + Unique node identifier domain. eg. ``"io.github.jchanvfx"`` + + .. important:: re-implement this attribute to provide a unique node type. + + .. code-block:: python + :linenos: + + from NodeGraphQt import NodeObject + + class ExampleNode(NodeObject): + + # unique node identifier domain. + __identifier__ = 'io.github.jchanvfx' + + def __init__(self): + ... + + :return: node type domain. + :rtype: str + + :meta hide-value: + """ + + NODE_NAME = None + """ + Initial base node name. + + .. important:: re-implement this attribute to provide a base node name. + + .. code-block:: python + :linenos: + + from NodeGraphQt import NodeObject + + class ExampleNode(NodeObject): + + # initial default node name. + NODE_NAME = 'Example Node' + + def __init__(self): + ... + + :return: node name + :rtype: str + + :meta hide-value: + """ + + def __init__(self, qgraphics_item=None): + """ + Args: + qgraphics_item (AbstractNodeItem): QGraphicsItem used for drawing. + """ + self._graph = None + self._model = NodeModel() + self._model.type_ = self.type_ + self._model.name = self.NODE_NAME + + _NodeItem = qgraphics_item + if _NodeItem is None: + raise RuntimeError( + 'No qgraphics item specified for the node object!' + ) + + self._view = _NodeItem() + self._view.type_ = self.type_ + self._view.name = self.model.name + self._view.id = self._model.id + self._view.layout_direction = self._model.layout_direction + + def __repr__(self): + return '<{}("{}") object at {}>'.format( + self.__class__.__name__, self.NODE_NAME, hex(id(self))) + + @_ClassProperty + def type_(cls): + """ + Node type identifier followed by the class name. + `eg.` ``"nodeGraphQt.nodes.NodeObject"`` + + Returns: + str: node type (``__identifier__.__className__``) + """ + return cls.__identifier__ + '.' + cls.__name__ + + @property + def id(self): + """ + The node unique id. + + Returns: + str: unique identifier string to the node. + """ + return self.model.id + + @property + def graph(self): + """ + The parent node graph. + + Returns: + NodeGraphQt.NodeGraph: node graph instance. + """ + return self._graph + + @property + def view(self): + """ + Returns the :class:`QtWidgets.QGraphicsItem` used in the scene. + + Returns: + NodeGraphQt.qgraphics.node_abstract.AbstractNodeItem: node item. + """ + return self._view + + def set_view(self, item): + """ + Set a new ``QGraphicsItem`` item to be used as the view. + (the provided qgraphics item must be subclassed from the + ``AbstractNodeItem`` object.) + + Args: + item (NodeGraphQt.qgraphics.node_abstract.AbstractNodeItem): node item. + """ + if self._view: + old_view = self._view + scene = self._view.scene() + scene.removeItem(old_view) + self._view = item + scene.addItem(self._view) + else: + self._view = item + self.NODE_NAME = self._view.name + + # update the view. + self.update() + + @property + def model(self): + """ + Return the node model. + + Returns: + NodeGraphQt.base.model.NodeModel: node model object. + """ + return self._model + + def set_model(self, model): + """ + Set a new model to the node model. + (Setting a new node model will also update the views qgraphics item.) + + Args: + model (NodeGraphQt.base.model.NodeModel): node model object. + """ + self._model = model + self._model.type_ = self.type_ + self._model.id = self.view.id + + # update the view. + self.update() + + def update_model(self): + """ + Update the node model from view. + """ + for name, val in self.view.properties.items(): + if name in self.model.properties.keys(): + setattr(self.model, name, val) + if name in self.model.custom_properties.keys(): + self.model.custom_properties[name] = val + + def update(self): + """ + Update the node view from model. + """ + settings = self.model.to_dict[self.model.id] + settings['id'] = self.model.id + self.view.from_dict(settings) + + def serialize(self): + """ + Serialize node model to a dictionary. + + example: + + .. highlight:: python + .. code-block:: python + + {'0x106cf75a8': { + 'name': 'foo node', + 'color': (48, 58, 69, 255), + 'border_color': (85, 100, 100, 255), + 'text_color': (255, 255, 255, 180), + 'type': 'io.github.jchanvfx.MyNode', + 'selected': False, + 'disabled': False, + 'visible': True, + 'inputs': { + : {: [, ]} + }, + 'outputs': { + : {: [, ]} + }, + 'input_ports': [, ], + 'output_ports': [, ], + 'width': 0.0, + 'height: 0.0, + 'pos': (0.0, 0.0), + 'layout_direction': 0, + 'custom': {}, + } + } + + Returns: + dict: serialized node + """ + return self.model.to_dict + + def name(self): + """ + Name of the node. + + Returns: + str: name of the node. + """ + return self.model.name + + def set_name(self, name=''): + """ + Set the name of the node. + + Args: + name (str): name for the node. + """ + self.set_property('name', name) + + def color(self): + """ + Returns the node color in (red, green, blue) value. + + Returns: + tuple: ``(r, g, b)`` from ``0-255`` range. + """ + r, g, b, a = self.model.color + return r, g, b + + def set_color(self, r=0, g=0, b=0): + """ + Sets the color of the node in (red, green, blue) value. + + Args: + r (int): red value ``0-255`` range. + g (int): green value ``0-255`` range. + b (int): blue value ``0-255`` range. + """ + self.set_property('color', (r, g, b, 255)) + + def disabled(self): + """ + Returns whether the node is enabled or disabled. + + Returns: + bool: True if the node is disabled. + """ + return self.model.disabled + + def set_disabled(self, mode=False): + """ + Set the node state to either disabled or enabled. + + Args: + mode(bool): True to disable node. + """ + self.set_property('disabled', mode) + + def selected(self): + """ + Returns the selected state of the node. + + Returns: + bool: True if the node is selected. + """ + self.model.selected = self.view.isSelected() + return self.model.selected + + def set_selected(self, selected=True): + """ + Set the node to be selected or not selected. + + Args: + selected (bool): True to select the node. + """ + self.set_property('selected', selected) + + def create_property(self, name, value, items=None, range=None, + widget_type=None, widget_tooltip=None, tab=None): + """ + Creates a custom property to the node. + + See Also: + Custom node properties bin widget + :class:`NodeGraphQt.PropertiesBinWidget` + + Hint: + To see all the available property widget types to display in + the ``PropertiesBinWidget`` widget checkout + :attr:`NodeGraphQt.constants.NodePropWidgetEnum`. + + Args: + name (str): name of the property. + value (object): data. + items (list[str]): items used by widget type + attr:`NodeGraphQt.constants.NodePropWidgetEnum.QCOMBO_BOX` + range (tuple or list): ``(min, max)`` values used by + :attr:`NodeGraphQt.constants.NodePropWidgetEnum.SLIDER` + widget_type (int): widget flag to display in the + :class:`NodeGraphQt.PropertiesBinWidget` + widget_tooltip (str): widget tooltip for the property widget + displayed in the :class:`NodeGraphQt.PropertiesBinWidget` + tab (str): name of the widget tab to display in the + :class:`NodeGraphQt.PropertiesBinWidget`. + """ + widget_type = widget_type or NodePropWidgetEnum.HIDDEN.value + self.model.add_property( + name, value, items, range, widget_type, widget_tooltip, tab + ) + + def properties(self): + """ + Returns all the node properties. + + Returns: + dict: a dictionary of node properties. + """ + props = self.model.to_dict[self.id].copy() + props['id'] = self.id + return props + + def get_property(self, name): + """ + Return the node custom property. + + Args: + name (str): name of the property. + + Returns: + object: property data. + """ + if self.graph and name == 'selected': + self.model.set_property(name, self.view.selected) + + return self.model.get_property(name) + + def set_property(self, name, value, push_undo=True): + """ + Set the value on the node custom property. + + Note: + When setting the node ``"name"`` property a new unique name will be + used if another node in the graph has the same node name. + + Args: + name (str): name of the property. + value (object): property data (python built in types). + push_undo (bool): register the command to the undo stack. (default: True) + """ + + # prevent signals from causing an infinite loop. + if self.get_property(name) == value: + return + + # prevent nodes from have the same name. + if self.graph and name == 'name': + value = self.graph.get_unique_name(value) + self.NODE_NAME = value + + if self.graph: + undo_cmd = PropertyChangedCmd(self, name, value) + if name == 'name': + undo_cmd.setText( + 'renamed "{}" to "{}"'.format(self.name(), value) + ) + if push_undo: + undo_stack = self.graph.undo_stack() + undo_stack.push(undo_cmd) + else: + undo_cmd.redo() + else: + if hasattr(self.view, name): + setattr(self.view, name, value) + self.model.set_property(name, value) + + # redraw the node for custom properties. + if self.model.is_custom_property(name): + self.view.draw_node() + + def has_property(self, name): + """ + Check if node custom property exists. + + Args: + name (str): name of the node. + + Returns: + bool: true if property name exists in the Node. + """ + return name in self.model.custom_properties.keys() + + def set_x_pos(self, x): + """ + Set the node horizontal X position in the node graph. + + Args: + x (float or int): node X position. + """ + y = self.pos()[1] + self.set_pos(float(x), y) + + def set_y_pos(self, y): + """ + Set the node horizontal Y position in the node graph. + + Args: + y (float or int): node Y position. + """ + + x = self.pos()[0] + self.set_pos(x, float(y)) + + def set_pos(self, x, y): + """ + Set the node X and Y position in the node graph. + + Args: + x (float or int): node X position. + y (float or int): node Y position. + """ + self.set_property('pos', [float(x), float(y)]) + + def x_pos(self): + """ + Get the node X position in the node graph. + + Returns: + float: x position. + """ + return self.model.pos[0] + + def y_pos(self): + """ + Get the node Y position in the node graph. + + Returns: + float: y position. + """ + return self.model.pos[1] + + def pos(self): + """ + Get the node XY position in the node graph. + + Returns: + list[float, float]: x, y position. + """ + if self.view.xy_pos and self.view.xy_pos != self.model.pos: + self.model.pos = self.view.xy_pos + + return self.model.pos + + def layout_direction(self): + """ + Returns layout direction for this node. + + See Also: + :meth:`NodeObject.set_layout_direction` + + Returns: + int: node layout direction. + """ + return self.model.layout_direction + + def set_layout_direction(self, value=0): + """ + Sets the node layout direction to either horizontal or vertical on + the current node only. + + `Implemented in` ``v0.3.0`` + + See Also: + :meth:`NodeGraph.set_layout_direction` + :meth:`NodeObject.layout_direction` + + Warnings: + This function does not register to the undo stack. + + Args: + value (int): layout direction mode. + """ + self.model.layout_direction = value + self.view.layout_direction = value diff --git a/cuegui/NodeGraphQt/base/port.py b/cuegui/NodeGraphQt/base/port.py new file mode 100644 index 000000000..d38820a61 --- /dev/null +++ b/cuegui/NodeGraphQt/base/port.py @@ -0,0 +1,495 @@ +#!/usr/bin/python +from NodeGraphQt.base.commands import ( + PortConnectedCmd, + PortDisconnectedCmd, + PortLockedCmd, + PortUnlockedCmd, + PortVisibleCmd, + NodeInputConnectedCmd, + NodeInputDisconnectedCmd +) +from NodeGraphQt.base.model import PortModel +from NodeGraphQt.constants import PortTypeEnum +from NodeGraphQt.errors import PortError + + +class Port(object): + """ + The ``Port`` class is used for connecting one node to another. + + .. inheritance-diagram:: NodeGraphQt.Port + + .. image:: _images/port.png + :width: 50% + + See Also: + For adding a ports into a node see: + :meth:`BaseNode.add_input`, :meth:`BaseNode.add_output` + + Args: + node (NodeGraphQt.NodeObject): parent node. + port (PortItem): graphic item used for drawing. + """ + + def __init__(self, node, port): + self.__view = port + self.__model = PortModel(node) + + def __repr__(self): + port = str(self.__class__.__name__) + return '<{}("{}") object at {}>'.format( + port, self.name(), hex(id(self))) + + @property + def view(self): + """ + Returns the :class:`QtWidgets.QGraphicsItem` used in the scene. + + Returns: + NodeGraphQt.qgraphics.port.PortItem: port item. + """ + return self.__view + + @property + def model(self): + """ + Returns the port model. + + Returns: + NodeGraphQt.base.model.PortModel: port model. + """ + return self.__model + + def type_(self): + """ + Returns the port type. + + Port Types: + - :attr:`NodeGraphQt.constants.IN_PORT` for input port + - :attr:`NodeGraphQt.constants.OUT_PORT` for output port + + Returns: + str: port connection type. + """ + return self.model.type_ + + def multi_connection(self): + """ + Returns if the ports is a single connection or not. + + Returns: + bool: false if port is a single connection port + """ + return self.model.multi_connection + + def node(self): + """ + Return the parent node. + + Returns: + NodeGraphQt.BaseNode: parent node object. + """ + return self.model.node + + def name(self): + """ + Returns the port name. + + Returns: + str: port name. + """ + return self.model.name + + def visible(self): + """ + Port visible in the node graph. + + Returns: + bool: true if visible. + """ + return self.model.visible + + def set_visible(self, visible=True, push_undo=True): + """ + Sets weather the port should be visible or not. + + Args: + visible (bool): true if visible. + push_undo (bool): register the command to the undo stack. (default: True) + """ + + # prevent signals from causing an infinite loop. + if visible == self.visible(): + return + + undo_cmd = PortVisibleCmd(self, visible) + if push_undo: + undo_stack = self.node().graph.undo_stack() + undo_stack.push(undo_cmd) + else: + undo_cmd.redo() + + def locked(self): + """ + Returns the locked state. + + If ports are locked then new pipe connections can't be connected + and current connected pipes can't be disconnected. + + Returns: + bool: true if locked. + """ + return self.model.locked + + def lock(self): + """ + Lock the port so new pipe connections can't be connected and + current connected pipes can't be disconnected. + + This is the same as calling :meth:`Port.set_locked` with the arg + set to ``True`` + """ + self.set_locked(True, connected_ports=True) + + def unlock(self): + """ + Unlock the port so new pipe connections can be connected and + existing connected pipes can be disconnected. + + This is the same as calling :meth:`Port.set_locked` with the arg + set to ``False`` + """ + self.set_locked(False, connected_ports=True) + + def set_locked(self, state=False, connected_ports=True, push_undo=True): + """ + Sets the port locked state. When locked pipe connections can't be + connected or disconnected from this port. + + Args: + state (Bool): port lock state. + connected_ports (Bool): apply to lock state to connected ports. + push_undo (bool): register the command to the undo stack. (default: True) + """ + + # prevent signals from causing an infinite loop. + if state == self.locked(): + return + + graph = self.node().graph + undo_stack = graph.undo_stack() + if state: + undo_cmd = PortLockedCmd(self) + else: + undo_cmd = PortUnlockedCmd(self) + if push_undo: + undo_stack.push(undo_cmd) + else: + undo_cmd.redo() + if connected_ports: + for port in self.connected_ports(): + port.set_locked(state, + connected_ports=False, + push_undo=push_undo) + + def connected_ports(self): + """ + Returns all connected ports. + + Returns: + list[NodeGraphQt.Port]: list of connected ports. + """ + ports = [] + graph = self.node().graph + for node_id, port_names in self.model.connected_ports.items(): + for port_name in port_names: + node = graph.get_node_by_id(node_id) + if self.type_() == PortTypeEnum.IN.value: + ports.append(node.outputs()[port_name]) + elif self.type_() == PortTypeEnum.OUT.value: + ports.append(node.inputs()[port_name]) + return ports + + def connect_to(self, port=None, push_undo=True, emit_signal=True): + """ + Create connection to the specified port and emits the + :attr:`NodeGraph.port_connected` signal from the parent node graph. + + Args: + port (NodeGraphQt.Port): port object. + push_undo (bool): register the command to the undo stack. (default: True) + emit_signal (bool): emit the port connection signals. (default: True) + """ + if not port: + return + + if self in port.connected_ports(): + return + + if self.locked() or port.locked(): + name = [p.name() for p in [self, port] if p.locked()][0] + raise PortError( + 'Can\'t connect port because "{}" is locked.'.format(name)) + + # validate accept connection. + node_type = self.node().type_ + accepted_types = port.accepted_port_types().get(node_type) + if accepted_types: + accepted_pnames = accepted_types.get(self.type_()) or set([]) + if self.name() not in accepted_pnames: + return + node_type = port.node().type_ + accepted_types = self.accepted_port_types().get(node_type) + if accepted_types: + accepted_pnames = accepted_types.get(port.type_()) or set([]) + if port.name() not in accepted_pnames: + return + + # validate reject connection. + node_type = self.node().type_ + rejected_types = port.rejected_port_types().get(node_type) + if rejected_types: + rejected_pnames = rejected_types.get(self.type_()) or set([]) + if self.name() in rejected_pnames: + return + node_type = port.node().type_ + rejected_types = self.rejected_port_types().get(node_type) + if rejected_types: + rejected_pnames = rejected_types.get(port.type_()) or set([]) + if port.name() in rejected_pnames: + return + + # make the connection from here. + graph = self.node().graph + viewer = graph.viewer() + + if push_undo: + undo_stack = graph.undo_stack() + undo_stack.beginMacro('connect port') + + pre_conn_port = None + src_conn_ports = self.connected_ports() + if not self.multi_connection() and src_conn_ports: + pre_conn_port = src_conn_ports[0] + + if not port: + if pre_conn_port: + if push_undo: + undo_stack.push( + PortDisconnectedCmd(self, port, emit_signal) + ) + undo_stack.push(NodeInputDisconnectedCmd(self, port)) + undo_stack.endMacro() + else: + PortDisconnectedCmd(self, port, emit_signal).redo() + NodeInputDisconnectedCmd(self, port).redo() + return + + if graph.acyclic() and viewer.acyclic_check(self.view, port.view): + if pre_conn_port: + if push_undo: + undo_stack.push( + PortDisconnectedCmd(self, pre_conn_port, emit_signal) + ) + undo_stack.push(NodeInputDisconnectedCmd( + self, pre_conn_port) + ) + undo_stack.endMacro() + else: + PortDisconnectedCmd(self, pre_conn_port, emit_signal).redo() + NodeInputDisconnectedCmd(self, pre_conn_port).redo() + return + + trg_conn_ports = port.connected_ports() + if not port.multi_connection() and trg_conn_ports: + dettached_port = trg_conn_ports[0] + if push_undo: + undo_stack.push( + PortDisconnectedCmd(port, dettached_port, emit_signal) + ) + undo_stack.push(NodeInputDisconnectedCmd(port, dettached_port)) + else: + PortDisconnectedCmd(port, dettached_port, emit_signal).redo() + NodeInputDisconnectedCmd(port, dettached_port).redo() + if pre_conn_port: + if push_undo: + undo_stack.push( + PortDisconnectedCmd(self, pre_conn_port, emit_signal) + ) + undo_stack.push(NodeInputDisconnectedCmd(self, pre_conn_port)) + else: + PortDisconnectedCmd(self, pre_conn_port, emit_signal).redo() + NodeInputDisconnectedCmd(self, pre_conn_port).redo() + + if push_undo: + undo_stack.push(PortConnectedCmd(self, port, emit_signal)) + undo_stack.push(NodeInputConnectedCmd(self, port)) + undo_stack.endMacro() + else: + PortConnectedCmd(self, port, emit_signal).redo() + NodeInputConnectedCmd(self, port).redo() + + def disconnect_from(self, port=None, push_undo=True, emit_signal=True): + """ + Disconnect from the specified port and emits the + :attr:`NodeGraph.port_disconnected` signal from the parent node graph. + + Args: + port (NodeGraphQt.Port): port object. + push_undo (bool): register the command to the undo stack. (default: True) + emit_signal (bool): emit the port connection signals. (default: True) + """ + if not port: + return + + if self.locked() or port.locked(): + name = [p.name() for p in [self, port] if p.locked()][0] + raise PortError( + 'Can\'t disconnect port because "{}" is locked.'.format(name)) + + graph = self.node().graph + if push_undo: + graph.undo_stack().beginMacro('disconnect port') + graph.undo_stack().push(PortDisconnectedCmd(self, port, emit_signal)) + graph.undo_stack().push(NodeInputDisconnectedCmd(self, port)) + graph.undo_stack().endMacro() + else: + PortDisconnectedCmd(self, port, emit_signal).redo() + NodeInputDisconnectedCmd(self, port).redo() + + def clear_connections(self, push_undo=True, emit_signal=True): + """ + Disconnect from all port connections and emit the + :attr:`NodeGraph.port_disconnected` signals from the node graph. + + See Also: + :meth:`Port.disconnect_from`, + :meth:`Port.connect_to`, + :meth:`Port.connected_ports` + + Args: + push_undo (bool): register the command to the undo stack. (default: True) + emit_signal (bool): emit the port connection signals. (default: True) + """ + if self.locked(): + err = 'Can\'t clear connections because port "{}" is locked.' + raise PortError(err.format(self.name())) + + if not self.connected_ports(): + return + + if push_undo: + graph = self.node().graph + undo_stack = graph.undo_stack() + undo_stack.beginMacro('"{}" clear connections') + for cp in self.connected_ports(): + self.disconnect_from(cp, emit_signal=emit_signal) + undo_stack.endMacro() + return + + for cp in self.connected_ports(): + self.disconnect_from( + cp, push_undo=False, emit_signal=emit_signal + ) + + def add_accept_port_type(self, port_name, port_type, node_type): + """ + Add a constraint to "accept" a pipe connection. + + Once a constraint has been added only ports of that type specified will + be allowed a pipe connection. + + `Implemented in` ``v0.6.0`` + + See Also: + :meth:`NodeGraphQt.Port.add_reject_ports_type`, + :meth:`NodeGraphQt.BaseNode.add_accept_port_type` + + Args: + port_name (str): name of the port. + port_type (str): port type. + node_type (str): port node type. + """ + # storing the connection constrain at the graph level instead of the + # port level, so we don't serialize the same data for every port + # instance. + self.node().add_accept_port_type( + port=self, + port_type_data={ + 'port_name': port_name, + 'port_type': port_type, + 'node_type': node_type, + } + ) + + def accepted_port_types(self): + """ + Returns a dictionary of connection constrains of the port types + that allow for a pipe connection to this node. + + See Also: + :meth:`NodeGraphQt.BaseNode.accepted_port_types` + + Returns: + dict: {: {: []}} + """ + return self.node().accepted_port_types(self) + + def add_reject_port_type(self, port_name, port_type, node_type): + """ + Add a constraint to "reject" a pipe connection. + + Once a constraint has been added only ports of that type specified will + be rejected a pipe connection. + + `Implemented in` ``v0.6.0`` + + See Also: + :meth:`NodeGraphQt.Port.add_accept_ports_type`, + :meth:`NodeGraphQt.BaseNode.add_reject_port_type` + + Args: + port_name (str): name of the port. + port_type (str): port type. + node_type (str): port node type. + """ + # storing the connection constrain at the graph level instead of the + # port level, so we don't serialize the same data for every port + # instance. + self.node().add_reject_port_type( + port=self, + port_type_data={ + 'port_name': port_name, + 'port_type': port_type, + 'node_type': node_type, + } + ) + + def rejected_port_types(self): + """ + Returns a dictionary of connection constrains of the port types + that are NOT allowed for a pipe connection to this node. + + See Also: + :meth:`NodeGraphQt.BaseNode.rejected_port_types` + + Returns: + dict: {: {: []}} + """ + return self.node().rejected_port_types(self) + + @property + def color(self): + return self.__view.color + + @color.setter + def color(self, color=(0, 0, 0, 255)): + self.__view.color = color + + @property + def border_color(self): + return self.__view.border_color + + @border_color.setter + def border_color(self, color=(0, 0, 0, 255)): + self.__view.border_color = color diff --git a/cuegui/NodeGraphQt/constants.py b/cuegui/NodeGraphQt/constants.py new file mode 100644 index 000000000..af0c412ee --- /dev/null +++ b/cuegui/NodeGraphQt/constants.py @@ -0,0 +1,254 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +import os + +from qtpy import QtWidgets +from enum import Enum + +from .pkg_info import __version__ as _v + +__doc__ = """ +| The :py:mod:`NodeGraphQt.constants` namespace contains variables and enums + used throughout the NodeGraphQt library. +""" + +# ================================== PRIVATE =================================== + +MIME_TYPE = 'nodegraphqt/nodes' +URI_SCHEME = 'nodegraphqt://' +URN_SCHEME = 'nodegraphqt::' + +# PATHS +BASE_PATH = os.path.dirname(os.path.abspath(__file__)) +ICON_PATH = os.path.join(BASE_PATH, 'widgets', 'icons') +ICON_DOWN_ARROW = os.path.join(ICON_PATH, 'down_arrow.png') +ICON_NODE_BASE = os.path.join(ICON_PATH, 'node_base.png') + +# DRAW STACK ORDER +Z_VAL_BACKDROP = -2 +Z_VAL_PIPE = -1 +Z_VAL_NODE = 1 +Z_VAL_PORT = 2 +Z_VAL_NODE_WIDGET = 3 + +# ITEM CACHE MODE +# QGraphicsItem.NoCache +# QGraphicsItem.DeviceCoordinateCache +# QGraphicsItem.ItemCoordinateCache +ITEM_CACHE_MODE = QtWidgets.QGraphicsItem.DeviceCoordinateCache + +# =================================== GLOBAL =================================== + + +class VersionEnum(Enum): + """ + Current framework version. + :py:mod:`NodeGraphQt.constants.VersionEnum` + """ + #: current version string. + VERSION = _v + #: version major int. + MAJOR = int(_v.split('.')[0]) + #: version minor int. + MINOR = int(_v.split('.')[1]) + #: version patch int. + PATCH = int(_v.split('.')[2]) + + +class LayoutDirectionEnum(Enum): + """ + Node graph nodes layout direction: + :py:mod:`NodeGraphQt.constants.ViewerLayoutEnum` + """ + #: layout nodes left to right. + HORIZONTAL = 0 + #: layout nodes top to bottom. + VERTICAL = 1 + + +# =================================== VIEWER =================================== + + +class ViewerEnum(Enum): + """ + Node graph viewer styling layout: + :py:mod:`NodeGraphQt.constants.ViewerEnum` + """ + #: default background color for the node graph. + BACKGROUND_COLOR = (35, 35, 35) + #: style node graph background with no grid or dots. + GRID_DISPLAY_NONE = 0 + #: style node graph background with dots. + GRID_DISPLAY_DOTS = 1 + #: style node graph background with grid lines. + GRID_DISPLAY_LINES = 2 + #: grid size when styled with grid lines. + GRID_SIZE = 50 + #: grid line color. + GRID_COLOR = (45, 45, 45) + + +class ViewerNavEnum(Enum): + """ + Node graph viewer navigation styling layout: + :py:mod:`NodeGraphQt.constants.ViewerNavEnum` + """ + #: default background color. + BACKGROUND_COLOR = (25, 25, 25) + #: default item color. + ITEM_COLOR = (35, 35, 35) + +# ==================================== NODE ==================================== + + +class NodeEnum(Enum): + """ + Node styling layout: + :py:mod:`NodeGraphQt.constants.NodeEnum` + """ + #: default node width. + WIDTH = 160 + #: default node height. + HEIGHT = 60 + #: default node icon size (WxH). + ICON_SIZE = 18 + #: default node overlay color when selected. + SELECTED_COLOR = (255, 255, 255, 30) + #: default node border color when selected. + SELECTED_BORDER_COLOR = (254, 207, 42, 255) + +# ==================================== PORT ==================================== + + +class PortEnum(Enum): + """ + Port styling layout: + :py:mod:`NodeGraphQt.constants.PortEnum` + """ + #: default port size. + SIZE = 22.0 + #: default port color. (r, g, b, a) + COLOR = (49, 115, 100, 255) + #: default port border color. + BORDER_COLOR = (29, 202, 151, 255) + #: port color when selected. + ACTIVE_COLOR = (14, 45, 59, 255) + #: port border color when selected. + ACTIVE_BORDER_COLOR = (107, 166, 193, 255) + #: port color on mouse over. + HOVER_COLOR = (17, 43, 82, 255) + #: port border color on mouse over. + HOVER_BORDER_COLOR = (136, 255, 35, 255) + #: threshold for selecting a port. + CLICK_FALLOFF = 15.0 + + +class PortTypeEnum(Enum): + """ + Port connection types: + :py:mod:`NodeGraphQt.constants.PortTypeEnum` + """ + #: Connection type for input ports. + IN = 'in' + #: Connection type for output ports. + OUT = 'out' + +# ==================================== PIPE ==================================== + + +class PipeEnum(Enum): + """ + Pipe styling layout: + :py:mod:`NodeGraphQt.constants.PipeEnum` + """ + #: default width. + WIDTH = 1.2 + #: default color. + COLOR = (175, 95, 30, 255) + #: pipe color to a node when it's disabled. + DISABLED_COLOR = (200, 60, 60, 255) + #: pipe color when selected or mouse over. + ACTIVE_COLOR = (70, 255, 220, 255) + #: pipe color to a node when it's selected. + HIGHLIGHT_COLOR = (232, 184, 13, 255) + #: draw connection as a line. + DRAW_TYPE_DEFAULT = 0 + #: draw connection as dashed lines. + DRAW_TYPE_DASHED = 1 + #: draw connection as a dotted line. + DRAW_TYPE_DOTTED = 2 + + +class PipeSlicerEnum(Enum): + """ + Slicer Pipe styling layout: + :py:mod:`NodeGraphQt.constants.PipeSlicerEnum` + """ + #: default width. + WIDTH = 1.5 + #: default color. + COLOR = (255, 50, 75) + + +class PipeLayoutEnum(Enum): + """ + Pipe connection drawing layout: + :py:mod:`NodeGraphQt.constants.PipeLayoutEnum` + """ + #: draw straight lines for pipe connections. + STRAIGHT = 0 + #: draw curved lines for pipe connections. + CURVED = 1 + #: draw angled lines for pipe connections. + ANGLE = 2 + + +# === PROPERTY BIN WIDGET === + +class NodePropWidgetEnum(Enum): + """ + Mapping used for the :class:`NodeGraphQt.PropertiesBinWidget` to display a + node property in the specified widget type. + + :py:mod:`NodeGraphQt.constants.NodePropWidgetEnum` + """ + #: Node property will be hidden in the ``PropertiesBinWidget`` (default). + HIDDEN = 0 + #: Node property represented with a ``QLabel`` widget. + QLABEL = 2 + #: Node property represented with a ``QLineEdit`` widget. + QLINE_EDIT = 3 + #: Node property represented with a ``QTextEdit`` widget. + QTEXT_EDIT = 4 + #: Node property represented with a ``QComboBox`` widget. + QCOMBO_BOX = 5 + #: Node property represented with a ``QCheckBox`` widget. + QCHECK_BOX = 6 + #: Node property represented with a ``QSpinBox`` widget. + QSPIN_BOX = 7 + #: Node property represented with a ``QDoubleSpinBox`` widget. + QDOUBLESPIN_BOX = 8 + #: Node property represented with a ColorPicker widget. + COLOR_PICKER = 9 + #: Node property represented with a ColorPicker (RGBA) widget. + COLOR4_PICKER = 10 + #: Node property represented with an (Int) Slider widget. + SLIDER = 11 + #: Node property represented with a (Dobule) Slider widget. + DOUBLE_SLIDER = 12 + #: Node property represented with a file selector widget. + FILE_OPEN = 13 + #: Node property represented with a file save widget. + FILE_SAVE = 14 + #: Node property represented with a vector2 widget. + VECTOR2 = 15 + #: Node property represented with vector3 widget. + VECTOR3 = 16 + #: Node property represented with vector4 widget. + VECTOR4 = 17 + #: Node property represented with float line edit widget. + FLOAT = 18 + #: Node property represented with int line edit widget. + INT = 19 + #: Node property represented with button widget. + BUTTON = 20 diff --git a/cuegui/NodeGraphQt/custom_widgets/__init__.py b/cuegui/NodeGraphQt/custom_widgets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cuegui/NodeGraphQt/custom_widgets/nodes_palette.py b/cuegui/NodeGraphQt/custom_widgets/nodes_palette.py new file mode 100644 index 000000000..d4563d653 --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/nodes_palette.py @@ -0,0 +1,346 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +from collections import defaultdict + +from qtpy import QtWidgets, QtCore, QtGui + +from NodeGraphQt.constants import MIME_TYPE, URN_SCHEME + + +class _NodesGridDelegate(QtWidgets.QStyledItemDelegate): + + def paint(self, painter, option, index): + """ + Args: + painter (QtGui.QPainter): + option (QtGui.QStyleOptionViewItem): + index (QtCore.QModelIndex): + """ + if index.column() != 0: + super(_NodesGridDelegate, self).paint(painter, option, index) + return + + model = index.model().sourceModel() + item = model.item(index.row(), index.column()) + + sub_margin = 2 + radius = 5 + + base_rect = QtCore.QRectF( + option.rect.x() + sub_margin, + option.rect.y() + sub_margin, + option.rect.width() - (sub_margin * 2), + option.rect.height() - (sub_margin * 2) + ) + + painter.save() + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + + # background. + bg_color = option.palette.window().color() + pen_color = option.palette.midlight().color().lighter(120) + if option.state & QtWidgets.QStyle.State_Selected: + bg_color = bg_color.lighter(120) + pen_color = pen_color.lighter(160) + + pen = QtGui.QPen(pen_color, 3.0) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(bg_color)) + painter.drawRoundedRect(base_rect, + int(base_rect.height()/radius), + int(base_rect.width()/radius)) + + if option.state & QtWidgets.QStyle.StateFlag.State_Selected: + pen_color = option.palette.highlight().color() + else: + pen_color = option.palette.midlight().color().darker(130) + pen = QtGui.QPen(pen_color, 1.0) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + + sub_margin = 6 + sub_rect = QtCore.QRectF( + base_rect.x() + sub_margin, + base_rect.y() + sub_margin, + base_rect.width() - (sub_margin * 2), + base_rect.height() - (sub_margin * 2) + ) + painter.drawRoundedRect(sub_rect, + int(sub_rect.height() / radius), + int(sub_rect.width() / radius)) + + painter.setBrush(QtGui.QBrush(pen_color)) + edge_size = 2, sub_rect.height() - 6 + left_x = sub_rect.left() + right_x = sub_rect.right() - edge_size[0] + pos_y = sub_rect.center().y() - (edge_size[1] / 2) + + for pos_x in [left_x, right_x]: + painter.drawRect(QtCore.QRectF( + pos_x, pos_y, edge_size[0], edge_size[1] + )) + + # painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QBrush(bg_color)) + dot_size = 4 + left_x = sub_rect.left() - 1 + right_x = sub_rect.right() - (dot_size - 1) + pos_y = sub_rect.center().y() - (dot_size / 2) + for pos_x in [left_x, right_x]: + painter.drawEllipse(QtCore.QRectF( + pos_x, pos_y, dot_size, dot_size + )) + pos_x -= dot_size + 2 + + # text + pen_color = option.palette.text().color() + pen = QtGui.QPen(pen_color, 0.5) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + + font = painter.font() + font_metrics = QtGui.QFontMetrics(font) + item_text = item.text().replace(' ', '_') + if hasattr(font_metrics, 'horizontalAdvance'): + font_width = font_metrics.horizontalAdvance(item_text) + else: + font_width = font_metrics.width(item_text) + font_height = font_metrics.height() + text_rect = QtCore.QRectF( + sub_rect.center().x() - (font_width / 2), + sub_rect.center().y() - (font_height * 0.55), + font_width, font_height) + painter.drawText(text_rect, item.text()) + painter.restore() + + +class _NodesGridProxyModel(QtCore.QSortFilterProxyModel): + + def __init__(self, parent=None): + super(_NodesGridProxyModel, self).__init__(parent) + + def mimeData(self, indexes, p_int=None): + node_ids = [ + 'node:{}'.format(i.data(QtCore.Qt.ItemDataRole.ToolTipRole)) + for i in indexes + ] + node_urn = URN_SCHEME + ';'.join(node_ids) + mime_data = QtCore.QMimeData() + mime_data.setData(MIME_TYPE, QtCore.QByteArray(node_urn.encode())) + return mime_data + + +class NodesGridView(QtWidgets.QListView): + + def __init__(self, parent=None): + super(NodesGridView, self).__init__(parent) + self.setSelectionMode(self.SelectionMode.ExtendedSelection) + self.setUniformItemSizes(True) + self.setResizeMode(self.ResizeMode.Adjust) + self.setViewMode(self.ViewMode.IconMode) + self.setDragDropMode(self.DragDropMode.DragOnly) + self.setDragEnabled(True) + self.setMinimumSize(300, 100) + self.setSpacing(4) + + model = QtGui.QStandardItemModel() + proxy_model = _NodesGridProxyModel() + proxy_model.setSourceModel(model) + self.setModel(proxy_model) + self.setItemDelegate(_NodesGridDelegate(self)) + + def clear(self): + self.model().sourceModel().clear() + + def add_item(self, label, tooltip=''): + item = QtGui.QStandardItem(label) + item.setSizeHint(QtCore.QSize(130, 40)) + item.setToolTip(tooltip) + model = self.model().sourceModel() + model.appendRow(item) + + +class NodesPaletteWidget(QtWidgets.QWidget): + """ + The :class:`NodeGraphQt.NodesPaletteWidget` is a widget for displaying all + registered nodes from the node graph in a grid layout with this widget a + user can create nodes by dragging and dropping. + + | *Implemented on NodeGraphQt:* ``v0.1.7`` + + .. inheritance-diagram:: NodeGraphQt.NodesPaletteWidget + :parts: 1 + + .. image:: ../_images/nodes_palette.png + :width: 400px + + .. code-block:: python + :linenos: + + from NodeGraphQt import NodeGraph, NodesPaletteWidget + + # create node graph. + graph = NodeGraph() + + # create nodes palette widget. + nodes_palette = NodesPaletteWidget(parent=None, node_graph=graph) + nodes_palette.show() + + Args: + parent (QtWidgets.QWidget): parent of the new widget. + node_graph (NodeGraphQt.NodeGraph): node graph. + """ + + def __init__(self, parent=None, node_graph=None): + super(NodesPaletteWidget, self).__init__(parent) + self.setWindowTitle('Nodes') + + self._category_tabs = {} + self._custom_labels = {} + self._factory = node_graph.node_factory if node_graph else None + + self._tab_widget = QtWidgets.QTabWidget() + self._tab_widget.setMovable(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self._tab_widget) + + self._build_ui() + + # update the ui if new nodes are registered post init. + node_graph.nodes_registered.connect(self._on_nodes_registered) + + def __repr__(self): + return '<{} object at {}>'.format( + self.__class__.__name__, hex(id(self)) + ) + + def _on_nodes_registered(self, nodes): + """ + Slot function when a new node has been registered into the node graph. + + Args: + nodes (list[NodeObject]): node objects. + """ + node_types = defaultdict(list) + for node in nodes: + name = node.NODE_NAME + node_type = node.type_ + category = '.'.join(node_type.split('.')[:-1]) + node_types[category].append((node_type, name)) + + update_tabs = False + for category, nodes_list in node_types.items(): + if not update_tabs and category not in self._category_tabs: + update_tabs = True + grid_view = self._add_category_tab(category) + for node_id, node_name in nodes_list: + grid_view.add_item(node_name, node_id) + + if update_tabs: + self._update_tab_labels() + + def _update_tab_labels(self): + """ + Update the tab labels. + """ + tabs_idx = {self._tab_widget.tabText(x): x + for x in range(self._tab_widget.count())} + for category, label in self._custom_labels.items(): + if category in tabs_idx: + idx = tabs_idx[category] + self._tab_widget.setTabText(idx, label) + + def _build_ui(self): + """ + populate the ui + """ + node_types = defaultdict(list) + for name, node_ids in self._factory.names.items(): + for nid in node_ids: + category = '.'.join(nid.split('.')[:-1]) + node_types[category].append((nid, name)) + + for category, nodes_list in node_types.items(): + grid_view = self._add_category_tab(category) + for node_id, node_name in nodes_list: + grid_view.add_item(node_name, node_id) + + def _set_node_factory(self, factory): + """ + Set current node factory. + + Args: + factory (NodeFactory): node factory. + """ + self._factory = factory + + def _add_category_tab(self, category): + """ + Adds a new tab to the node palette widget. + + Args: + category (str): node identifier category eg. ``"nodes.widgets"`` + + Returns: + NodesGridView: nodes grid view widget. + """ + if category not in self._category_tabs: + grid_widget = NodesGridView(self) + self._tab_widget.addTab(grid_widget, category) + self._category_tabs[category] = grid_widget + return self._category_tabs[category] + + def set_category_label(self, category, label): + """ + Override tab label for a node category tab. + + Args: + category (str): node identifier category eg. ``"nodes.widgets"`` + label (str): custom display label. eg. ``"Node Widgets"`` + """ + if label in self._custom_labels.values(): + labels = {v: k for k, v in self._custom_labels.items()} + raise ValueError('label "{}" already in use for "{}"' + .format(label, labels[label])) + previous_label = self._custom_labels.get(category, '') + for idx in range(self._tab_widget.count()): + tab_text = self._tab_widget.tabText(idx) + if tab_text in [category, previous_label]: + self._tab_widget.setTabText(idx, label) + break + self._custom_labels[category] = label + + def tab_widget(self): + """ + Get the tab widget. + + Returns: + QtWidgets.QTabWidget: tab widget. + """ + return self._tab_widget + + def update(self): + """ + Update and refresh the node palette widget. + """ + for category, grid_view in self._category_tabs.items(): + grid_view.clear() + + node_types = defaultdict(list) + for name, node_ids in self._factory.names.items(): + for nid in node_ids: + category = '.'.join(nid.split('.')[:-1]) + node_types[category].append((nid, name)) + + for category, nodes_list in node_types.items(): + grid_view = self._category_tabs.get(category) + if not grid_view: + grid_view = self._add_category_tab(category) + + for node_id, node_name in nodes_list: + grid_view.add_item(node_name, node_id) + + self._update_tab_labels() diff --git a/cuegui/NodeGraphQt/custom_widgets/nodes_tree.py b/cuegui/NodeGraphQt/custom_widgets/nodes_tree.py new file mode 100644 index 000000000..d6f73ae98 --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/nodes_tree.py @@ -0,0 +1,141 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +from qtpy import QtWidgets, QtCore, QtGui + +from NodeGraphQt.constants import MIME_TYPE, URN_SCHEME + +TYPE_NODE = QtWidgets.QTreeWidgetItem.UserType + 1 +TYPE_CATEGORY = QtWidgets.QTreeWidgetItem.UserType + 2 + + +class _BaseNodeTreeItem(QtWidgets.QTreeWidgetItem): + + def __eq__(self, other): + """ + Workaround fix for QTreeWidgetItem "operator not implemented error". + see link: https://bugreports.qt.io/browse/PYSIDE-74 + """ + return id(self) == id(other) + + +class NodesTreeWidget(QtWidgets.QTreeWidget): + """ + The :class:`NodeGraphQt.NodesTreeWidget` is a widget for displaying all + registered nodes from the node graph with this widget a user can create + nodes by dragging and dropping. + + .. inheritance-diagram:: NodeGraphQt.NodesTreeWidget + :parts: 1 + :top-classes: PySide2.QtWidgets.QWidget + + .. image:: ../_images/nodes_tree.png + :width: 300px + + .. code-block:: python + :linenos: + + from NodeGraphQt import NodeGraph, NodesTreeWidget + + # create node graph. + graph = NodeGraph() + + # create node tree widget. + nodes_tree = NodesTreeWidget(parent=None, node_graph=graph) + nodes_tree.show() + + Args: + parent (QtWidgets.QWidget): parent of the new widget. + node_graph (NodeGraphQt.NodeGraph): node graph. + """ + + def __init__(self, parent=None, node_graph=None): + super(NodesTreeWidget, self).__init__(parent) + self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.DragOnly) + self.setSelectionMode(self.SelectionMode.ExtendedSelection) + self.setHeaderHidden(True) + self.setWindowTitle('Nodes') + + self._factory = node_graph.node_factory if node_graph else None + self._custom_labels = {} + self._category_items = {} + + self._build_tree() + + def __repr__(self): + return '<{} object at {}>'.format( + self.__class__.__name__, hex(id(self)) + ) + + def mimeData(self, items): + node_ids = ['node:{}'.format(i.toolTip(0)) for i in items] + node_urn = URN_SCHEME + ';'.join(node_ids) + mime_data = QtCore.QMimeData() + mime_data.setData(MIME_TYPE, QtCore.QByteArray(node_urn.encode())) + return mime_data + + def _build_tree(self): + """ + Populate the node tree. + """ + self.clear() + categories = set() + node_types = {} + for name, node_ids in self._factory.names.items(): + for nid in node_ids: + categories.add('.'.join(nid.split('.')[:-1])) + node_types[nid] = name + + self._category_items = {} + for category in sorted(categories): + if category in self._custom_labels.keys(): + label = self._custom_labels[category] + else: + label = '{}'.format(category) + cat_item = _BaseNodeTreeItem(self, [label], type=TYPE_CATEGORY) + cat_item.setFirstColumnSpanned(True) + cat_item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled) + cat_item.setSizeHint(0, QtCore.QSize(100, 26)) + self.addTopLevelItem(cat_item) + cat_item.setExpanded(True) + self._category_items[category] = cat_item + + for node_id, node_name in node_types.items(): + category = '.'.join(node_id.split('.')[:-1]) + category_item = self._category_items[category] + + item = _BaseNodeTreeItem(category_item, [node_name], type=TYPE_NODE) + item.setToolTip(0, node_id) + item.setSizeHint(0, QtCore.QSize(100, 26)) + + category_item.addChild(item) + + def _set_node_factory(self, factory): + """ + Set current node factory. + + Args: + factory (NodeFactory): node factory. + """ + self._factory = factory + + def set_category_label(self, category, label): + """ + Override the label for a node category root item. + + .. image:: ../_images/nodes_tree_category_label.png + :width: 70% + + Args: + category (str): node identifier category eg. ``"nodes.widgets"`` + label (str): custom display label. eg. ``"Node Widgets"`` + """ + self._custom_labels[category] = label + if category in self._category_items: + item = self._category_items[category] + item.setText(0, label) + + def update(self): + """ + Update and refresh the node tree widget. + """ + self._build_tree() diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/__init__.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py new file mode 100644 index 000000000..e93750e28 --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py @@ -0,0 +1,119 @@ +#!/usr/bin/python +from qtpy import QtWidgets, QtCore, QtGui + +from .custom_widget_vectors import PropVector3, PropVector4 +from .prop_widgets_abstract import BaseProperty + + +class PropColorPickerRGB(BaseProperty): + """ + Color picker widget for a node property. + """ + + def __init__(self, parent=None): + super(PropColorPickerRGB, self).__init__(parent) + self._color = (0, 0, 0) + self._button = QtWidgets.QPushButton() + self._vector = PropVector3() + self._vector.set_steps([1, 10, 100]) + self._vector.set_data_type(int) + self._vector.set_value([0, 0, 0]) + self._vector.set_min(0) + self._vector.set_max(255) + self._update_color() + + self._button.clicked.connect(self._on_select_color) + self._vector.value_changed.connect(self._on_vector_changed) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._button, 0, QtCore.Qt.AlignmentFlag.AlignLeft) + layout.addWidget(self._vector, 1, QtCore.Qt.AlignmentFlag.AlignLeft) + + def _on_vector_changed(self, _, value): + self._color = tuple(value) + self._update_color() + self.value_changed.emit(self.get_name(), value) + + def _on_select_color(self): + current_color = QtGui.QColor(*self.get_value()) + color = QtWidgets.QColorDialog.getColor(current_color, self) + if color.isValid(): + self.set_value(color.getRgb()) + + def _update_vector(self): + self._vector.set_value(self._color) + + def _update_color(self): + c = [int(max(min(i, 255), 0)) for i in self._color] + hex_color = '#{0:02x}{1:02x}{2:02x}'.format(*c) + self._button.setStyleSheet( + ''' + QPushButton {{background-color: rgba({0}, {1}, {2}, 255);}} + QPushButton::hover {{background-color: rgba({0}, {1}, {2}, 200);}} + '''.format(*c) + ) + self._button.setToolTip( + 'rgb: {}\nhex: {}'.format(self._color[:3], hex_color) + ) + + def set_data_type(self, data_type): + """ + Sets the input line edit fields to either display in float or int. + + Args: + data_type(int or float): int or float data type object. + """ + self._vector.set_data_type(data_type) + + def get_value(self): + return self._color[:3] + + def set_value(self, value): + if value != self.get_value(): + self._color = value + self._update_color() + self._update_vector() + self.value_changed.emit(self.get_name(), value) + + +class PropColorPickerRGBA(PropColorPickerRGB): + """ + Color4 (rgba) picker widget for a node property. + """ + + def __init__(self, parent=None): + BaseProperty.__init__(self, parent) + self._color = (0, 0, 0, 255) + self._button = QtWidgets.QPushButton() + self._vector = PropVector4() + self._vector.set_steps([1, 10, 100]) + self._vector.set_data_type(int) + self._vector.set_value([0, 0, 0, 255]) + self._vector.set_min(0) + self._vector.set_max(255) + self._update_color() + + self._button.clicked.connect(self._on_select_color) + self._vector.value_changed.connect(self._on_vector_changed) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._button, 0, QtCore.Qt.AlignmentFlag.AlignLeft) + layout.addWidget(self._vector, 1, QtCore.Qt.AlignmentFlag.AlignLeft) + + def _update_color(self): + c = [int(max(min(i, 255), 0)) for i in self._color] + hex_color = '#{0:02x}{1:02x}{2:02x}{3:03x}'.format(*c) + self._button.setStyleSheet( + ''' + QPushButton {{background-color: rgba({0}, {1}, {2}, {3});}} + QPushButton::hover {{background-color: rgba({0}, {1}, {2}, {3});}} + '''.format(*c) + ) + self._button.setToolTip( + 'rgba: {}\nhex: {}'.format(self._color, hex_color) + ) + + def get_value(self): + return self._color[:4] diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py new file mode 100644 index 000000000..b2b940963 --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py @@ -0,0 +1,76 @@ +#!/usr/bin/python +from qtpy import QtWidgets, QtCore + +from NodeGraphQt.widgets.dialogs import FileDialog +from .prop_widgets_abstract import BaseProperty + + +class PropFilePath(BaseProperty): + """ + Displays a node property as a "QFileDialog" open widget in the + PropertiesBin. + """ + + def __init__(self, parent=None): + super(PropFilePath, self).__init__(parent) + self._ledit = QtWidgets.QLineEdit() + self._ledit.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + self._ledit.editingFinished.connect(self._on_value_change) + self._ledit.clearFocus() + + icon = self.style().standardIcon(QtWidgets.QStyle.StandardPixmap(21)) + _button = QtWidgets.QPushButton() + _button.setIcon(icon) + _button.clicked.connect(self._on_select_file) + + hbox = QtWidgets.QHBoxLayout(self) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.addWidget(self._ledit) + hbox.addWidget(_button) + + self._ext = '*' + self._file_directory = None + + def _on_select_file(self): + file_path = FileDialog.getOpenFileName(self, + file_dir=self._file_directory, + ext_filter=self._ext) + file = file_path[0] or None + if file: + self.set_value(file) + + def _on_value_change(self, value=None): + if value is None: + value = self._ledit.text() + self.set_file_directory(value) + self.value_changed.emit(self.get_name(), value) + + def set_file_ext(self, ext=None): + self._ext = ext or '*' + + def set_file_directory(self, directory): + self._file_directory = directory + + def get_value(self): + return self._ledit.text() + + def set_value(self, value): + _value = str(value) + if _value != self.get_value(): + self._ledit.setText(_value) + self._on_value_change(_value) + + +class PropFileSavePath(PropFilePath): + """ + Displays a node property as a "QFileDialog" save widget in the + PropertiesBin. + """ + + def _on_select_file(self): + file_path = FileDialog.getSaveFileName(self, + file_dir=self._file_directory, + ext_filter=self._ext) + file = file_path[0] or None + if file: + self.set_value(file) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py new file mode 100644 index 000000000..17dd2fa70 --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py @@ -0,0 +1,132 @@ +#!/usr/bin/python +from qtpy import QtWidgets, QtCore + +from .prop_widgets_abstract import BaseProperty + + +class PropSlider(BaseProperty): + """ + Displays a node property as a "Slider" widget in the PropertiesBin + widget. + """ + + def __init__(self, parent=None, disable_scroll=True, realtime_update=False): + super(PropSlider, self).__init__(parent) + self._block = False + self._realtime_update = realtime_update + self._disable_scroll = disable_scroll + self._slider = QtWidgets.QSlider() + self._spinbox = QtWidgets.QSpinBox() + self._init() + self._init_signal_connections() + + def _init(self): + self._slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + self._slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBelow) + self._slider.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Preferred) + self._spinbox.setButtonSymbols(QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._spinbox) + layout.addWidget(self._slider) + # store the original press event. + self._slider_mouse_press_event = self._slider.mousePressEvent + self._slider.mousePressEvent = self._on_slider_mouse_press + self._slider.mouseReleaseEvent = self._on_slider_mouse_release + + if self._disable_scroll: + self._slider.wheelEvent = lambda _: None + self._spinbox.wheelEvent = lambda _: None + + def _init_signal_connections(self): + self._spinbox.valueChanged.connect(self._on_spnbox_changed) + self._slider.valueChanged.connect(self._on_slider_changed) + + def _on_slider_mouse_press(self, event): + self._block = True + self._slider_mouse_press_event(event) + + def _on_slider_mouse_release(self, event): + if not self._realtime_update: + self.value_changed.emit(self.get_name(), self.get_value()) + self._block = False + + def _on_slider_changed(self, value): + self._spinbox.setValue(value) + if self._realtime_update: + self.value_changed.emit(self.get_name(), self.get_value()) + + def _on_spnbox_changed(self, value): + if value != self._slider.value(): + self._slider.setValue(value) + if not self._block: + self.value_changed.emit(self.get_name(), self.get_value()) + + def get_value(self): + return self._spinbox.value() + + def set_value(self, value): + if value != self.get_value(): + self._block = True + self._spinbox.setValue(value) + self.value_changed.emit(self.get_name(), value) + self._block = False + + def set_min(self, value=0): + self._spinbox.setMinimum(value) + self._slider.setMinimum(value) + + def set_max(self, value=0): + self._spinbox.setMaximum(value) + self._slider.setMaximum(value) + + +class QDoubleSlider(QtWidgets.QSlider): + double_value_changed = QtCore.Signal(float) + + def __init__(self, decimals=2, *args, **kargs): + super(QDoubleSlider, self).__init__(*args, **kargs) + self._multiplier = 10 ** decimals + + self.valueChanged.connect(self._on_value_change) + + def _on_value_change(self): + value = float(super(QDoubleSlider, self).value()) / self._multiplier + self.double_value_changed.emit(value) + + def value(self): + return float(super(QDoubleSlider, self).value()) / self._multiplier + + def setMinimum(self, value): + return super(QDoubleSlider, self).setMinimum(value * self._multiplier) + + def setMaximum(self, value): + return super(QDoubleSlider, self).setMaximum(value * self._multiplier) + + def setSingleStep(self, value): + return super(QDoubleSlider, self).setSingleStep(value * self._multiplier) + + def singleStep(self): + return float(super(QDoubleSlider, self).singleStep()) / self._multiplier + + def setValue(self, value): + super(QDoubleSlider, self).setValue(int(value * self._multiplier)) + + +class PropDoubleSlider(PropSlider): + def __init__(self, parent=None, decimals=2, disable_scroll=True, realtime_update=False): + # Do not initialize Propslider, just its parents + super(PropSlider, self).__init__(parent) + self._block = False + self._realtime_update = realtime_update + self._disable_scroll = disable_scroll + self._slider = QDoubleSlider(decimals=decimals) + self._spinbox = QtWidgets.QDoubleSpinBox() + self._init() + self._init_signal_connections() + + def _init_signal_connections(self): + self._spinbox.valueChanged.connect(self._on_spnbox_changed) + # Connect to double_value_changed instead valueChanged + self._slider.double_value_changed.connect(self._on_slider_changed) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py new file mode 100644 index 000000000..007c033ec --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py @@ -0,0 +1,303 @@ +#!/usr/bin/python +import re + +from qtpy import QtWidgets, QtCore, QtGui + +_NUMB_REGEX = re.compile(r'^((?:\-)*\d+)*([\.,])*(\d+(?:[eE](?:[\-\+])*\d+)*)*') + + +class _NumberValueMenu(QtWidgets.QMenu): + + mouseMove = QtCore.Signal(object) + mouseRelease = QtCore.Signal(object) + stepChange = QtCore.Signal() + + def __init__(self, parent=None): + super(_NumberValueMenu, self).__init__(parent) + self.step = 1 + self.steps = [] + self.last_action = None + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + # re-implemented. + + def mousePressEvent(self, event): + """ + Disabling the mouse press event. + """ + return + + def mouseReleaseEvent(self, event): + """ + Additional functionality to emit signal. + """ + self.mouseRelease.emit(event) + super(_NumberValueMenu, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + """ + Additional functionality to emit step changed signal. + """ + self.mouseMove.emit(event) + super(_NumberValueMenu, self).mouseMoveEvent(event) + action = self.actionAt(event.pos()) + if action: + if action is not self.last_action: + self.stepChange.emit() + self.last_action = action + self.step = action.step + elif self.last_action: + self.setActiveAction(self.last_action) + + def _add_step_action(self, step): + action = QtWidgets.QAction(str(step), self) + action.step = step + self.addAction(action) + + def set_steps(self, steps): + self.clear() + self.steps = steps + for step in steps: + self._add_step_action(step) + + def set_data_type(self, data_type): + if data_type is int: + new_steps = [] + for step in self.steps: + if '.' not in str(step): + new_steps.append(step) + self.set_steps(new_steps) + elif data_type is float: + self.set_steps(self.steps) + + +class _NumberValueEdit(QtWidgets.QLineEdit): + + value_changed = QtCore.Signal(object) + + def __init__(self, parent=None, data_type=float): + super(_NumberValueEdit, self).__init__(parent) + self.setToolTip('"MMB + Drag Left/Right" to change values.') + self.setText('0') + + self._MMB_STATE = False + self._previous_x = None + self._previous_value = None + self._step = 1 + self._speed = 0.05 + self._data_type = float + self._min = None + self._max = None + + self._menu = _NumberValueMenu() + self._menu.mouseMove.connect(self.mouseMoveEvent) + self._menu.mouseRelease.connect(self.mouseReleaseEvent) + self._menu.stepChange.connect(self._reset_previous_x) + + self.editingFinished.connect(self._on_editing_finished) + + self.set_data_type(data_type) + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + # re-implemented + + def mouseMoveEvent(self, event): + if self._MMB_STATE: + if self._previous_x is None: + self._previous_x = event.x() + self._previous_value = self.get_value() + else: + self._step = self._menu.step + delta = event.x() - self._previous_x + value = self._previous_value + value = value + int(delta * self._speed) * self._step + self.set_value(value) + self._on_mmb_mouse_move() + super(_NumberValueEdit, self).mouseMoveEvent(event) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.MiddleButton: + self._MMB_STATE = True + self._reset_previous_x() + self._menu.exec_(QtGui.QCursor.pos()) + super(_NumberValueEdit, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + self._menu.close() + self._MMB_STATE = False + super(_NumberValueEdit, self).mouseReleaseEvent(event) + + def keyPressEvent(self, event): + super(_NumberValueEdit, self).keyPressEvent(event) + if event.key() == QtCore.Qt.Key.Key_Up: + return + elif event.key() == QtCore.Qt.Key.Key_Down: + return + + # private + + def _reset_previous_x(self): + self._previous_x = None + + def _on_mmb_mouse_move(self): + self.value_changed.emit(self.get_value()) + + def _on_editing_finished(self): + if self._data_type is float: + match = _NUMB_REGEX.match(self.text()) + if match: + val1, point, val2 = match.groups() + if point: + val1 = val1 or '0' + val2 = val2 or '0' + self.setText(val1 + point + val2) + self.value_changed.emit(self.get_value()) + + def _convert_text(self, text): + """ + Convert text to int or float. + + Args: + text (str): input text. + + Returns: + int or float: converted value. + """ + match = _NUMB_REGEX.match(text) + if match: + val1, _, val2 = match.groups() + val1 = val1 or '0' + val2 = val2 or '0' + value = float(val1 + '.' + val2) + else: + value = 0.0 + if self._data_type is int: + value = int(value) + return value + + # public + + def set_data_type(self, data_type): + """ + Sets the line edit to either display value in float or int. + + Args: + data_type(int or float): int or float data type object. + """ + self._data_type = data_type + if data_type is int: + regexp = QtCore.QRegularExpression(r'\d+') + validator = QtGui.QRegularExpressionValidator(regexp, self) + steps = [1, 10, 100, 1000] + self._min = None if self._min is None else int(self._min) + self._max = None if self._max is None else int(self._max) + elif data_type is float: + regexp = QtCore.QRegularExpression(r'\d+[\.,]\d+(?:[eE](?:[\-\+]|)\d+)*') + validator = QtGui.QRegularExpressionValidator(regexp, self) + steps = [0.001, 0.01, 0.1, 1] + self._min = None if self._min is None else float(self._min) + self._max = None if self._max is None else float(self._max) + + self.setValidator(validator) + if not self._menu.steps: + self._menu.set_steps(steps) + self._menu.set_data_type(data_type) + + def set_steps(self, steps=None): + """ + Sets the step items in the MMB context menu. + + Args: + steps (list[int] or list[float]): list of ints or floats. + """ + step_types = { + int: [1, 10, 100, 1000], + float: [0.001, 0.01, 0.1, 1] + } + steps = steps or step_types.get(self._data_type) + self._menu.set_steps(steps) + + def set_min(self, value=None): + """ + Set the minimum range for the input field. + + Args: + value (int or float): minimum range value. + """ + if self._data_type is int: + self._min = int(value) + elif self._data_type is float: + self._min = float(value) + else: + self._min = value + + def set_max(self, value=None): + """ + Set the maximum range for the input field. + + Args: + value (int or float): maximum range value. + """ + if self._data_type is int: + self._max = int(value) + elif self._data_type is float: + self._max = float(value) + else: + self._max = value + + def get_value(self): + value = self._convert_text(self.text()) + return value + + def set_value(self, value): + text = str(value) + converted = self._convert_text(text) + current = self.get_value() + if converted == current: + return + point = None + if isinstance(converted, float): + point = _NUMB_REGEX.match(str(value)).groups(2) + if self._min is not None and converted < self._min: + text = str(self._min) + if point and point not in text: + text = str(self._min).replace('.', point) + if self._max is not None and converted > self._max: + text = str(self._max) + if point and point not in text: + text = text.replace('.', point) + self.setText(text) + + +class IntValueEdit(_NumberValueEdit): + + def __init__(self, parent=None): + super(IntValueEdit, self).__init__(parent, data_type=int) + + +class FloatValueEdit(_NumberValueEdit): + + def __init__(self, parent=None): + super(FloatValueEdit, self).__init__(parent, data_type=float) + + +if __name__ == '__main__': + app = QtWidgets.QApplication([]) + + int_edit = IntValueEdit() + int_edit.set_steps([1, 10]) + float_edit = FloatValueEdit() + + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + layout.addWidget(int_edit) + layout.addWidget(float_edit) + widget.show() + + app.exec_() diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py new file mode 100644 index 000000000..3c1d918ec --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py @@ -0,0 +1,138 @@ +#!/usr/bin/python +from qtpy import QtWidgets + +from .custom_widget_value_edit import _NumberValueEdit +from .prop_widgets_abstract import BaseProperty + + +class _PropVector(BaseProperty): + """ + Base widget for the PropVector widgets. + """ + + def __init__(self, parent=None, fields=0): + super(_PropVector, self).__init__(parent) + self._value = [] + self._items = [] + self._can_emit = True + + layout = QtWidgets.QHBoxLayout(self) + layout.setSpacing(2) + layout.setContentsMargins(0, 0, 0, 0) + for i in range(fields): + self._add_item(i) + + def _add_item(self, index): + _ledit = _NumberValueEdit() + _ledit.index = index + _ledit.value_changed.connect( + lambda: self._on_value_change(_ledit.get_value(), _ledit.index) + ) + + self.layout().addWidget(_ledit) + self._value.append(0.0) + self._items.append(_ledit) + + def _on_value_change(self, value=None, index=None): + if self._can_emit: + if index is not None: + self._value = list(self._value) + self._value[index] = value + self.value_changed.emit(self.get_name(), self._value) + + def _update_items(self): + if not isinstance(self._value, (list, tuple)): + raise TypeError('Value "{}" must be either list or tuple.' + .format(self._value)) + for index, value in enumerate(self._value): + if (index + 1) > len(self._items): + continue + if self._items[index].get_value() != value: + self._items[index].set_value(value) + + def set_data_type(self, data_type): + """ + Sets the input line edit fields to either display in float or int. + + Args: + data_type(int or float): int or float data type object. + """ + for item in self._items: + item.set_data_type(data_type) + + def set_steps(self, steps): + """ + Sets the step items in the MMB context menu. + + Args: + steps (list[int] or list[float]): list of ints or floats. + """ + for item in self._items: + item.set_steps(steps) + + def set_min(self, value): + """ + Set the minimum range for the input fields. + + Args: + value (int or float): minimum range value. + """ + for item in self._items: + item.set_min(value) + + def set_max(self, value): + """ + Set the maximum range for the input fields. + + Args: + value (int or float): maximum range value. + """ + for item in self._items: + item.set_max(value) + + def get_value(self): + return self._value + + def set_value(self, value=None): + if value != self.get_value(): + self._value = value + self._can_emit = False + self._update_items() + self._can_emit = True + self._on_value_change() + + +class PropVector2(_PropVector): + """ + Displays a node property as a "Vector2" widget in the PropertiesBin + widget. + + Useful for display X,Y data. + """ + + def __init__(self, parent=None): + super(PropVector2, self).__init__(parent, 2) + + +class PropVector3(_PropVector): + """ + Displays a node property as a "Vector3" widget in the PropertiesBin + widget. + + Useful for displaying x,y,z data. + """ + + def __init__(self, parent=None): + super(PropVector3, self).__init__(parent, 3) + + +class PropVector4(_PropVector): + """ + Displays a node property as a "Vector4" widget in the PropertiesBin + widget. + + Useful for display r,g,b,a data. + """ + + def __init__(self, parent=None): + super(PropVector4, self).__init__(parent, 4) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py new file mode 100644 index 000000000..d4c175fcf --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py @@ -0,0 +1,60 @@ +from NodeGraphQt.constants import NodePropWidgetEnum +from .custom_widget_color_picker import PropColorPickerRGB, PropColorPickerRGBA +from .custom_widget_file_paths import PropFilePath, PropFileSavePath +from .custom_widget_slider import PropSlider, PropDoubleSlider +from .custom_widget_value_edit import FloatValueEdit, IntValueEdit +from .custom_widget_vectors import PropVector2, PropVector3, PropVector4 +from .prop_widgets_base import ( + PropLabel, + PropLineEdit, + PropTextEdit, + PropComboBox, + PropCheckBox, + PropSpinBox, + PropDoubleSpinBox +) + + +class NodePropertyWidgetFactory(object): + """ + Node property widget factory for mapping the corresponding property widget + to the Properties bin. + """ + + def __init__(self): + self._widget_mapping = { + NodePropWidgetEnum.HIDDEN.value: None, + # base widgets. + NodePropWidgetEnum.QLABEL.value: PropLabel, + NodePropWidgetEnum.QLINE_EDIT.value: PropLineEdit, + NodePropWidgetEnum.QTEXT_EDIT.value: PropTextEdit, + NodePropWidgetEnum.QCOMBO_BOX.value: PropComboBox, + NodePropWidgetEnum.QCHECK_BOX.value: PropCheckBox, + NodePropWidgetEnum.QSPIN_BOX.value: PropSpinBox, + NodePropWidgetEnum.QDOUBLESPIN_BOX.value: PropDoubleSpinBox, + # custom widgets. + NodePropWidgetEnum.COLOR_PICKER.value: PropColorPickerRGB, + NodePropWidgetEnum.COLOR4_PICKER.value: PropColorPickerRGBA, + NodePropWidgetEnum.SLIDER.value: PropSlider, + NodePropWidgetEnum.DOUBLE_SLIDER.value: PropDoubleSlider, + NodePropWidgetEnum.FILE_OPEN.value: PropFilePath, + NodePropWidgetEnum.FILE_SAVE.value: PropFileSavePath, + NodePropWidgetEnum.VECTOR2.value: PropVector2, + NodePropWidgetEnum.VECTOR3.value: PropVector3, + NodePropWidgetEnum.VECTOR4.value: PropVector4, + NodePropWidgetEnum.FLOAT.value: FloatValueEdit, + NodePropWidgetEnum.INT.value: IntValueEdit, + } + + def get_widget(self, widget_type=NodePropWidgetEnum.HIDDEN.value): + """ + Return a new instance of a node property widget. + + Args: + widget_type (int): widget type index. + + Returns: + BaseProperty: node property widget. + """ + if widget_type in self._widget_mapping: + return self._widget_mapping[widget_type]() diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py new file mode 100644 index 000000000..086a78a5b --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py @@ -0,0 +1,873 @@ +#!/usr/bin/python +from collections import defaultdict + +from qtpy import QtWidgets, QtCore, QtGui + +from .node_property_factory import NodePropertyWidgetFactory +from .prop_widgets_base import PropLineEdit + + +class _PropertiesDelegate(QtWidgets.QStyledItemDelegate): + + def paint(self, painter, option, index): + """ + Args: + painter (QtGui.QPainter): + option (QtGui.QStyleOptionViewItem): + index (QtCore.QModelIndex): + """ + painter.save() + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, False) + painter.setPen(QtCore.Qt.NoPen) + + # draw background. + bg_clr = option.palette.base().color() + painter.setBrush(QtGui.QBrush(bg_clr)) + painter.drawRect(option.rect) + + # draw border. + border_width = 1 + if option.state & QtWidgets.QStyle.State_Selected: + bdr_clr = option.palette.highlight().color() + painter.setPen(QtGui.QPen(bdr_clr, 1.5)) + else: + bdr_clr = option.palette.alternateBase().color() + painter.setPen(QtGui.QPen(bdr_clr, 1)) + + painter.setBrush(QtCore.Qt.NoBrush) + painter.drawRect(QtCore.QRect( + option.rect.x() + border_width, + option.rect.y() + border_width, + option.rect.width() - (border_width * 2), + option.rect.height() - (border_width * 2)) + ) + painter.restore() + + +class _PropertiesList(QtWidgets.QTableWidget): + + def __init__(self, parent=None): + super(_PropertiesList, self).__init__(parent) + self.setItemDelegate(_PropertiesDelegate()) + self.setColumnCount(1) + self.setShowGrid(False) + self.verticalHeader().hide() + self.horizontalHeader().hide() + + QtWidgets.QHeaderView.setSectionResizeMode( + self.verticalHeader(), QtWidgets.QHeaderView.ResizeMode.ResizeToContents + ) + QtWidgets.QHeaderView.setSectionResizeMode( + self.horizontalHeader(), 0, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + + def wheelEvent(self, event): + """ + Args: + event (QtGui.QWheelEvent): + """ + delta = event.angleDelta().y() * 0.2 + self.verticalScrollBar().setValue( + self.verticalScrollBar().value() - delta + ) + + +class _PropertiesContainer(QtWidgets.QWidget): + """ + Node properties container widget that displays nodes properties under + a tab in the ``NodePropWidget`` widget. + """ + + def __init__(self, parent=None): + super(_PropertiesContainer, self).__init__(parent) + self.__layout = QtWidgets.QGridLayout() + self.__layout.setColumnStretch(1, 1) + self.__layout.setSpacing(6) + + layout = QtWidgets.QVBoxLayout(self) + layout.setAlignment(QtCore.Qt.AlignTop) + layout.addLayout(self.__layout) + + self.__property_widgets = {} + + def __repr__(self): + return '<{} object at {}>'.format( + self.__class__.__name__, hex(id(self)) + ) + + def add_widget(self, name, widget, value, label=None, tooltip=None): + """ + Add a property widget to the window. + + Args: + name (str): property name to be displayed. + widget (BaseProperty): property widget. + value (object): property value. + label (str): custom label to display. + tooltip (str): custom tooltip. + """ + label = label or name + label_widget = QtWidgets.QLabel(label) + if tooltip: + widget.setToolTip('{}\n{}'.format(name, tooltip)) + label_widget.setToolTip('{}\n{}'.format(name, tooltip)) + else: + widget.setToolTip(name) + label_widget.setToolTip(name) + widget.set_value(value) + row = self.__layout.rowCount() + if row > 0: + row += 1 + + label_flags = QtCore.Qt.AlignmentFlag.AlignCenter | QtCore.Qt.AlignmentFlag.AlignRight + if widget.__class__.__name__ == 'PropTextEdit': + label_flags = label_flags | QtCore.Qt.AlignmentFlag.AlignTop + + self.__layout.addWidget(label_widget, row, 0, label_flags) + self.__layout.addWidget(widget, row, 1) + self.__property_widgets[name] = widget + + def get_widget(self, name): + """ + Returns the property widget from the name. + + Args: + name (str): property name. + + Returns: + QtWidgets.QWidget: property widget. + """ + return self.__property_widgets.get(name) + + def get_all_widgets(self): + """ + Returns the node property widgets. + + Returns: + dict: {name: widget} + """ + return self.__property_widgets + + +class _PortConnectionsContainer(QtWidgets.QWidget): + """ + Port connection container widget that displays node ports and connections + under a tab in the ``NodePropWidget`` widget. + """ + + def __init__(self, parent=None, node=None): + super(_PortConnectionsContainer, self).__init__(parent) + self._node = node + self._ports = {} + + self.input_group, self.input_tree = self._build_tree_group( + 'Input Ports' + ) + self.input_group.setToolTip('Display input port connections') + for _, port in node.inputs().items(): + self._build_row(self.input_tree, port) + for col in range(self.input_tree.columnCount()): + self.input_tree.resizeColumnToContents(col) + + self.output_group, self.output_tree = self._build_tree_group( + 'Output Ports' + ) + self.output_group.setToolTip('Display output port connections') + for _, port in node.outputs().items(): + self._build_row(self.output_tree, port) + for col in range(self.output_tree.columnCount()): + self.output_tree.resizeColumnToContents(col) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.input_group) + layout.addWidget(self.output_group) + layout.addStretch() + + self.input_group.setChecked(False) + self.input_tree.setVisible(False) + self.output_group.setChecked(False) + self.output_tree.setVisible(False) + + def __repr__(self): + return '<{} object at {}>'.format( + self.__class__.__name__, hex(id(self)) + ) + + @staticmethod + def _build_tree_group(title): + """ + Build the ports group box and ports tree widget. + + Args: + title (str): group box title. + + Returns: + tuple(QtWidgets.QGroupBox, QtWidgets.QTreeWidget): widgets. + """ + group_box = QtWidgets.QGroupBox() + group_box.setMaximumHeight(200) + group_box.setCheckable(True) + group_box.setChecked(True) + group_box.setTitle(title) + group_box.setLayout(QtWidgets.QVBoxLayout()) + + headers = ['Locked', 'Name', 'Connections', ''] + tree_widget = QtWidgets.QTreeWidget() + tree_widget.setColumnCount(len(headers)) + tree_widget.setHeaderLabels(headers) + tree_widget.setHeaderHidden(False) + tree_widget.header().setStretchLastSection(False) + QtWidgets.QHeaderView.setSectionResizeMode( + tree_widget.header(), 2, QtWidgets.QHeaderView.Stretch + ) + + group_box.layout().addWidget(tree_widget) + + return group_box, tree_widget + + def _build_row(self, tree, port): + """ + Builds a new row in the parent ports tree widget. + + Args: + tree (QtWidgets.QTreeWidget): parent port tree widget. + port (NodeGraphQt.Port): port object. + """ + item = QtWidgets.QTreeWidgetItem(tree) + item.setFlags(item.flags() & ~QtCore.Qt.ItemIsSelectable) + item.setText(1, port.name()) + item.setToolTip(0, 'Lock Port') + item.setToolTip(1, 'Port Name') + item.setToolTip(2, 'Select connected port.') + item.setToolTip(3, 'Center on connected port node.') + + # TODO: will need to update this checkbox lock logic to work with + # the undo/redo functionality. + lock_chb = QtWidgets.QCheckBox() + lock_chb.setChecked(port.locked()) + lock_chb.clicked.connect(lambda x: port.set_locked(x)) + tree.setItemWidget(item, 0, lock_chb) + + combo = QtWidgets.QComboBox() + for cp in port.connected_ports(): + item_name = '{} : "{}"'.format(cp.name(), cp.node().name()) + self._ports[item_name] = cp + combo.addItem(item_name) + tree.setItemWidget(item, 2, combo) + + focus_btn = QtWidgets.QPushButton() + focus_btn.setIcon(QtGui.QIcon( + tree.style().standardPixmap(QtWidgets.QStyle.SP_DialogYesButton) + )) + focus_btn.clicked.connect( + lambda: self._on_focus_to_node(self._ports.get(combo.currentText())) + ) + tree.setItemWidget(item, 3, focus_btn) + + def _on_focus_to_node(self, port): + """ + Slot function emits the node is of the connected port. + + Args: + port (NodeGraphQt.Port): connected port. + """ + if port: + node = port.node() + node.graph.center_on([node]) + node.graph.clear_selection() + node.set_selected(True) + + def set_lock_controls_disable(self, disable=False): + """ + Enable/Disable port lock column widgets. + + Args: + disable (bool): true to disable checkbox. + """ + for r in range(self.input_tree.topLevelItemCount()): + item = self.input_tree.topLevelItem(r) + chb_widget = self.input_tree.itemWidget(item, 0) + chb_widget.setDisabled(disable) + for r in range(self.output_tree.topLevelItemCount()): + item = self.output_tree.topLevelItem(r) + chb_widget = self.output_tree.itemWidget(item, 0) + chb_widget.setDisabled(disable) + + +class NodePropEditorWidget(QtWidgets.QWidget): + """ + Node properties editor widget for display a Node object. + + Args: + parent (QtWidgets.QWidget): parent object. + node (NodeGraphQt.NodeObject): node. + """ + + #: signal (node_id, prop_name, prop_value) + property_changed = QtCore.Signal(str, str, object) + property_closed = QtCore.Signal(str) + + def __init__(self, parent=None, node=None): + super(NodePropEditorWidget, self).__init__(parent) + self.__node_id = node.id + self.__tab_windows = {} + self.__tab = QtWidgets.QTabWidget() + + close_btn = QtWidgets.QPushButton() + close_btn.setIcon(QtGui.QIcon( + self.style().standardPixmap( + QtWidgets.QStyle.StandardPixmap.SP_DialogCloseButton + ) + )) + close_btn.setMaximumWidth(40) + close_btn.setToolTip('close property') + close_btn.clicked.connect(self._on_close) + + self.name_wgt = PropLineEdit() + self.name_wgt.set_name('name') + self.name_wgt.setToolTip('name\nSet the node name.') + self.name_wgt.set_value(node.name()) + self.name_wgt.value_changed.connect(self._on_property_changed) + + self.type_wgt = QtWidgets.QLabel(node.type_) + self.type_wgt.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + self.type_wgt.setToolTip( + 'type_\nNode type identifier followed by the class name.' + ) + font = self.type_wgt.font() + font.setPointSize(10) + self.type_wgt.setFont(font) + + name_layout = QtWidgets.QHBoxLayout() + name_layout.setContentsMargins(0, 0, 0, 0) + name_layout.addWidget(QtWidgets.QLabel('name')) + name_layout.addWidget(self.name_wgt) + name_layout.addWidget(close_btn) + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(4) + layout.addLayout(name_layout) + layout.addWidget(self.__tab) + layout.addWidget(self.type_wgt) + + self._port_connections = self._read_node(node) + + def __repr__(self): + return '<{} object at {}>'.format( + self.__class__.__name__, hex(id(self)) + ) + + def _on_close(self): + """ + called by the close button. + """ + self.property_closed.emit(self.__node_id) + + def _on_property_changed(self, name, value): + """ + slot function called when a property widget has changed. + + Args: + name (str): property name. + value (object): new value. + """ + self.property_changed.emit(self.__node_id, name, value) + + def _read_node(self, node): + """ + Populate widget from a node. + + Args: + node (NodeGraphQt.BaseNode): node class. + + Returns: + _PortConnectionsContainer: ports container widget. + """ + model = node.model + graph_model = node.graph.model + + common_props = graph_model.get_node_common_properties(node.type_) + + # sort tabs and properties. + tab_mapping = defaultdict(list) + for prop_name, prop_val in model.custom_properties.items(): + tab_name = model.get_tab_name(prop_name) + tab_mapping[tab_name].append((prop_name, prop_val)) + + # add tabs. + reserved_tabs = ['Node', 'Ports'] + for tab in sorted(tab_mapping.keys()): + if tab in reserved_tabs: + print('tab name "{}" is reserved by the "NodePropWidget" ' + 'please use a different tab name.') + continue + self.add_tab(tab) + + # property widget factory. + widget_factory = NodePropertyWidgetFactory() + + # populate tab properties. + for tab in sorted(tab_mapping.keys()): + prop_window = self.__tab_windows[tab] + for prop_name, value in tab_mapping[tab]: + wid_type = model.get_widget_type(prop_name) + if wid_type == 0: + continue + + widget = widget_factory.get_widget(wid_type) + widget.set_name(prop_name) + + tooltip = None + if prop_name in common_props.keys(): + if 'items' in common_props[prop_name].keys(): + widget.set_items(common_props[prop_name]['items']) + if 'range' in common_props[prop_name].keys(): + prop_range = common_props[prop_name]['range'] + widget.set_min(prop_range[0]) + widget.set_max(prop_range[1]) + if 'tooltip' in common_props[prop_name].keys(): + tooltip = common_props[prop_name]['tooltip'] + prop_window.add_widget( + name=prop_name, + widget=widget, + value=value, + label=prop_name.replace('_', ' '), + tooltip=tooltip + ) + widget.value_changed.connect(self._on_property_changed) + + # add "Node" tab properties. (default props) + self.add_tab('Node') + default_props = { + 'color': 'Node base color.', + 'text_color': 'Node text color.', + 'border_color': 'Node border color.', + 'disabled': 'Disable/Enable node state.', + 'id': 'Unique identifier string to the node.' + } + prop_window = self.__tab_windows['Node'] + for prop_name, tooltip in default_props.items(): + wid_type = model.get_widget_type(prop_name) + widget = widget_factory.get_widget(wid_type) + widget.set_name(prop_name) + prop_window.add_widget( + name=prop_name, + widget=widget, + value=model.get_property(prop_name), + label=prop_name.replace('_', ' '), + tooltip=tooltip + ) + + widget.value_changed.connect(self._on_property_changed) + + self.type_wgt.setText(model.get_property('type_') or '') + + # add "ports" tab connections. + ports_container = None + if node.inputs() or node.outputs(): + ports_container = _PortConnectionsContainer(self, node=node) + self.__tab.addTab(ports_container, 'Ports') + + # hide/remove empty tabs with no property widgets. + tab_index = { + self.__tab.tabText(x): x for x in range(self.__tab.count()) + } + current_idx = None + for tab_name, prop_window in self.__tab_windows.items(): + prop_widgets = prop_window.get_all_widgets() + if not prop_widgets: + # I prefer to hide the tab but in older version of pyside this + # attribute doesn't exist we'll just remove. + if hasattr(self.__tab, 'setTabVisible'): + self.__tab.setTabVisible(tab_index[tab_name], False) + else: + self.__tab.removeTab(tab_index[tab_name]) + continue + if current_idx is None: + current_idx = tab_index[tab_name] + + self.__tab.setCurrentIndex(current_idx) + + return ports_container + + def node_id(self): + """ + Returns the node id linked to the widget. + + Returns: + str: node id + """ + return self.__node_id + + def add_widget(self, name, widget, tab='Properties'): + """ + add new node property widget. + + Args: + name (str): property name. + widget (BaseProperty): property widget. + tab (str): tab name. + """ + if tab not in self._widgets.keys(): + tab = 'Properties' + window = self.__tab_windows[tab] + window.add_widget(name, widget) + widget.value_changed.connect(self._on_property_changed) + + def add_tab(self, name): + """ + add a new tab. + + Args: + name (str): tab name. + + Returns: + PropListWidget: tab child widget. + """ + if name in self.__tab_windows.keys(): + raise AssertionError('Tab name {} already taken!'.format(name)) + self.__tab_windows[name] = _PropertiesContainer(self) + self.__tab.addTab(self.__tab_windows[name], name) + return self.__tab_windows[name] + + def get_tab_widget(self): + """ + Returns the underlying tab widget. + + Returns: + QtWidgets.QTabWidget: tab widget. + """ + return self.__tab + + def get_widget(self, name): + """ + get property widget. + + Args: + name (str): property name. + + Returns: + NodeGraphQt.custom_widgets.properties_bin.prop_widgets_abstract.BaseProperty: property widget. + """ + if name == 'name': + return self.name_wgt + for prop_win in self.__tab_windows.values(): + widget = prop_win.get_widget(name) + if widget: + return widget + + def get_all_property_widgets(self): + """ + get all the node property widgets. + + Returns: + list[BaseProperty]: property widgets. + """ + widgets = [self.name_wgt] + for prop_win in self.__tab_windows.values(): + for widget in prop_win.get_all_widgets().values(): + widgets.append(widget) + return widgets + + def get_port_connection_widget(self): + """ + Returns the ports connections container widget. + + Returns: + _PortConnectionsContainer: port container widget. + """ + return self._port_connections + + def set_port_lock_widgets_disabled(self, disabled=True): + """ + Enable/Disable port lock column widgets. + + Args: + disabled (bool): true to disable checkbox. + """ + self._port_connections.set_lock_controls_disable(disabled) + + +class PropertiesBinWidget(QtWidgets.QWidget): + """ + The :class:`NodeGraphQt.PropertiesBinWidget` is a list widget for displaying + and editing a nodes properties. + + .. inheritance-diagram:: NodeGraphQt.PropertiesBinWidget + :parts: 1 + + .. image:: ../_images/prop_bin.png + :width: 950px + + .. code-block:: python + :linenos: + + from NodeGraphQt import NodeGraph, PropertiesBinWidget + + # create node graph. + graph = NodeGraph() + + # create properties bin widget. + properties_bin = PropertiesBinWidget(parent=None, node_graph=graph) + properties_bin.show() + + See Also: + :meth:`NodeGraphQt.BaseNode.add_custom_widget`, + :meth:`NodeGraphQt.NodeObject.create_property`, + :attr:`NodeGraphQt.constants.NodePropWidgetEnum` + + Args: + parent (QtWidgets.QWidget): parent of the new widget. + node_graph (NodeGraphQt.NodeGraph): node graph. + """ + + #: Signal emitted (node_id, prop_name, prop_value) + property_changed = QtCore.Signal(str, str, object) + + def __init__(self, parent=None, node_graph=None): + super(PropertiesBinWidget, self).__init__(parent) + self.setWindowTitle('Properties Bin') + self._prop_list = _PropertiesList() + self._limit = QtWidgets.QSpinBox() + self._limit.setToolTip('Set display nodes limit.') + self._limit.setMaximum(10) + self._limit.setMinimum(0) + self._limit.setValue(2) + self._limit.valueChanged.connect(self.__on_limit_changed) + self.resize(450, 400) + + # this attribute to block signals if for the "on_property_changed" signal + # in case devs that don't implement the ".prop_widgets_abstract.BaseProperty" + # widget properly to prevent an infinite loop. + self._block_signal = False + + self._lock = False + self._btn_lock = QtWidgets.QPushButton('Lock') + self._btn_lock.setToolTip( + 'Lock the properties bin prevent nodes from being loaded.') + self._btn_lock.clicked.connect(self.lock_bin) + + btn_clr = QtWidgets.QPushButton('Clear') + btn_clr.setToolTip('Clear the properties bin.') + btn_clr.clicked.connect(self.clear_bin) + + top_layout = QtWidgets.QHBoxLayout() + top_layout.setSpacing(2) + top_layout.addWidget(self._limit) + top_layout.addStretch(1) + top_layout.addWidget(self._btn_lock) + top_layout.addWidget(btn_clr) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(top_layout) + layout.addWidget(self._prop_list, 1) + + # wire up node graph. + node_graph.add_properties_bin(self) + node_graph.node_double_clicked.connect(self.add_node) + node_graph.nodes_deleted.connect(self.__on_nodes_deleted) + node_graph.property_changed.connect(self.__on_graph_property_changed) + + def __repr__(self): + return '<{} object at {}>'.format( + self.__class__.__name__, hex(id(self)) + ) + + def __on_port_tree_visible_changed(self, node_id, visible, tree_widget): + """ + Triggered when the visibility of the port tree widget changes we + resize the property list table row. + + Args: + node_id (str): node id. + visible (bool): visibility state. + tree_widget (QtWidgets.QTreeWidget): ports tree widget. + """ + items = self._prop_list.findItems(node_id, QtCore.Qt.MatchExactly) + if items: + tree_widget.setVisible(visible) + widget = self._prop_list.cellWidget(items[0].row(), 0) + widget.adjustSize() + QtWidgets.QHeaderView.setSectionResizeMode( + self._prop_list.verticalHeader(), + QtWidgets.QHeaderView.ResizeToContents + ) + + def __on_prop_close(self, node_id): + """ + Triggered when a node property widget is requested to be removed from + the property list widget. + + Args: + node_id (str): node id. + """ + items = self._prop_list.findItems(node_id, QtCore.Qt.MatchFlag.MatchExactly) + [self._prop_list.removeRow(i.row()) for i in items] + + def __on_limit_changed(self, value): + """ + Sets the property list widget limit. + + Args: + value (int): limit value. + """ + rows = self._prop_list.rowCount() + if rows > value: + self._prop_list.removeRow(rows - 1) + + def __on_nodes_deleted(self, nodes): + """ + Slot function when a node has been deleted. + + Args: + nodes (list[str]): list of node ids. + """ + [self.__on_prop_close(n) for n in nodes] + + def __on_graph_property_changed(self, node, prop_name, prop_value): + """ + Slot function that updates the property bin from the node graph signal. + + Args: + node (NodeGraphQt.NodeObject): + prop_name (str): node property name. + prop_value (object): node property value. + """ + properties_widget = self.get_property_editor_widget(node) + if not properties_widget: + return + + property_widget = properties_widget.get_widget(prop_name) + + if property_widget and prop_value != property_widget.get_value(): + self._block_signal = True + property_widget.set_value(prop_value) + self._block_signal = False + + def __on_property_widget_changed(self, node_id, prop_name, prop_value): + """ + Slot function triggered when a property widget value has changed. + + Args: + node_id (str): node id. + prop_name (str): node property name. + prop_value (object): node property value. + """ + if not self._block_signal: + self.property_changed.emit(node_id, prop_name, prop_value) + + def create_property_editor(self, node): + """ + Creates a new property editor widget from the provided node. + + (re-implement for displaying custom node property editor widget.) + + Args: + node (NodeGraphQt.NodeObject): node object. + + Returns: + NodePropEditorWidget: property editor widget. + """ + return NodePropEditorWidget(node=node) + + def limit(self): + """ + Returns the limit for how many nodes can be loaded into the bin. + + Returns: + int: node limit. + """ + return int(self._limit.value()) + + def set_limit(self, limit): + """ + Set limit of nodes to display. + + Args: + limit (int): node limit. + """ + self._limit.setValue(limit) + + def add_node(self, node): + """ + Add node to the properties bin. + + Args: + node (NodeGraphQt.NodeObject): node object. + """ + if self.limit() == 0 or self._lock: + return + + rows = self._prop_list.rowCount() - 1 + if rows >= self.limit(): + self._prop_list.removeRow(rows - 1) + + itm_find = self._prop_list.findItems(node.id, QtCore.Qt.MatchFlag.MatchExactly) + if itm_find: + self._prop_list.removeRow(itm_find[0].row()) + + self._prop_list.insertRow(0) + + prop_widget = self.create_property_editor(node=node) + prop_widget.property_closed.connect(self.__on_prop_close) + prop_widget.property_changed.connect(self.__on_property_widget_changed) + port_connections = prop_widget.get_port_connection_widget() + if port_connections: + port_connections.input_group.clicked.connect( + lambda v: self.__on_port_tree_visible_changed( + prop_widget.node_id(), v, port_connections.input_tree + ) + ) + port_connections.output_group.clicked.connect( + lambda v: self.__on_port_tree_visible_changed( + prop_widget.node_id(), v, port_connections.output_tree + ) + ) + + self._prop_list.setCellWidget(0, 0, prop_widget) + + item = QtWidgets.QTableWidgetItem(node.id) + self._prop_list.setItem(0, 0, item) + self._prop_list.selectRow(0) + + def remove_node(self, node): + """ + Remove node from the properties bin. + + Args: + node (str or NodeGraphQt.BaseNode): node id or node object. + """ + node_id = node if isinstance(node, str) else node.id + self.__on_prop_close(node_id) + + def lock_bin(self): + """ + Lock/UnLock the properties bin. + """ + self._lock = not self._lock + if self._lock: + self._btn_lock.setText('UnLock') + else: + self._btn_lock.setText('Lock') + + def clear_bin(self): + """ + Clear the properties bin. + """ + self._prop_list.setRowCount(0) + + def get_property_editor_widget(self, node): + """ + Returns the node property editor widget. + + Args: + node (str or NodeGraphQt.NodeObject): node id or node object. + + Returns: + NodePropEditorWidget: node property editor widget. + """ + node_id = node if isinstance(node, str) else node.id + itm_find = self._prop_list.findItems(node_id, QtCore.Qt.MatchFlag.MatchExactly) + if itm_find: + item = itm_find[0] + return self._prop_list.cellWidget(item.row(), 0) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py new file mode 100644 index 000000000..5d60dc5da --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py @@ -0,0 +1,49 @@ +#!/usr/bin/python +from qtpy import QtWidgets, QtCore + + +class BaseProperty(QtWidgets.QWidget): + """ + Base class for a custom node property widget to be displayed in the + PropertiesBin widget. + + Inherits from: :class:`PySide2.QtWidgets.QWidget` + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(BaseProperty, self).__init__(parent) + self._name = None + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def get_name(self): + """ + Returns: + str: property name matching the node property. + """ + return self._name + + def set_name(self, name): + """ + Args: + name (str): property name matching the node property. + """ + self._name = name + + def get_value(self): + """ + Returns: + object: widgets current value. + """ + raise NotImplementedError + + def set_value(self, value): + """ + Args: + value (object): property value to update the widget. + """ + raise NotImplementedError diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py new file mode 100644 index 000000000..01e517ab2 --- /dev/null +++ b/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py @@ -0,0 +1,305 @@ +#!/usr/bin/python +from qtpy import QtWidgets, QtCore + + +class PropLabel(QtWidgets.QLabel): + """ + Displays a node property as a "QLabel" widget in the PropertiesBin widget. + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(PropLabel, self).__init__(parent) + self._name = None + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + def get_value(self): + return self.text() + + def set_value(self, value): + if value != self.get_value(): + self.setText(str(value)) + self.value_changed.emit(self.get_name(), value) + + +class PropLineEdit(QtWidgets.QLineEdit): + """ + Displays a node property as a "QLineEdit" widget in the PropertiesBin + widget. + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(PropLineEdit, self).__init__(parent) + self._name = None + self.editingFinished.connect(self._on_editing_finished) + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def _on_editing_finished(self): + self.value_changed.emit(self.get_name(), self.text()) + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + def get_value(self): + return self.text() + + def set_value(self, value): + _value = str(value) + if _value != self.get_value(): + self.setText(_value) + self.value_changed.emit(self.get_name(), _value) + + +class PropTextEdit(QtWidgets.QTextEdit): + """ + Displays a node property as a "QTextEdit" widget in the PropertiesBin + widget. + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(PropTextEdit, self).__init__(parent) + self._name = None + self._prev_text = '' + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def focusInEvent(self, event): + super(PropTextEdit, self).focusInEvent(event) + self._prev_text = self.toPlainText() + + def focusOutEvent(self, event): + super(PropTextEdit, self).focusOutEvent(event) + if self._prev_text != self.toPlainText(): + self.value_changed.emit(self.get_name(), self.toPlainText()) + self._prev_text = '' + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + def get_value(self): + return self.toPlainText() + + def set_value(self, value): + _value = str(value) + if _value != self.get_value(): + self.setPlainText(_value) + self.value_changed.emit(self.get_name(), _value) + + +class PropComboBox(QtWidgets.QComboBox): + """ + Displays a node property as a "QComboBox" widget in the PropertiesBin + widget. + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(PropComboBox, self).__init__(parent) + self._name = None + self.currentIndexChanged.connect(self._on_index_changed) + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def _on_index_changed(self): + self.value_changed.emit(self.get_name(), self.get_value()) + + def items(self): + """ + Returns items from the combobox. + + Returns: + list[str]: list of strings. + """ + return [self.itemText(i) for i in range(self.count())] + + def set_items(self, items): + """ + Set items on the combobox. + + Args: + items (list[str]): list of strings. + """ + self.clear() + self.addItems(items) + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + def get_value(self): + return self.currentText() + + def set_value(self, value): + if value != self.get_value(): + idx = self.findText(value, QtCore.Qt.MatchFlag.MatchExactly) + self.setCurrentIndex(idx) + if idx >= 0: + self.value_changed.emit(self.get_name(), value) + + +class PropCheckBox(QtWidgets.QCheckBox): + """ + Displays a node property as a "QCheckBox" widget in the PropertiesBin + widget. + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(PropCheckBox, self).__init__(parent) + self._name = None + self.clicked.connect(self._on_clicked) + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def _on_clicked(self): + self.value_changed.emit(self.get_name(), self.get_value()) + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + def get_value(self): + return self.isChecked() + + def set_value(self, value): + _value = bool(value) + if _value != self.get_value(): + self.setChecked(_value) + self.value_changed.emit(self.get_name(), _value) + + +class PropSpinBox(QtWidgets.QSpinBox): + """ + Displays a node property as a "QSpinBox" widget in the PropertiesBin widget. + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(PropSpinBox, self).__init__(parent) + self._name = None + self.setButtonSymbols(self.ButtonSymbols.NoButtons) + self.valueChanged.connect(self._on_value_change) + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def _on_value_change(self, value): + self.value_changed.emit(self.get_name(), value) + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + def get_value(self): + return self.value() + + def set_value(self, value): + if value != self.get_value(): + self.setValue(value) + + +class PropDoubleSpinBox(QtWidgets.QDoubleSpinBox): + """ + Displays a node property as a "QDoubleSpinBox" widget in the PropertiesBin + widget. + """ + + value_changed = QtCore.Signal(str, object) + + def __init__(self, parent=None): + super(PropDoubleSpinBox, self).__init__(parent) + self._name = None + self.setButtonSymbols(self.ButtonSymbols.NoButtons) + self.valueChanged.connect(self._on_value_change) + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def _on_value_change(self, value): + self.value_changed.emit(self.get_name(), value) + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + def get_value(self): + return self.value() + + def set_value(self, value): + if value != self.get_value(): + self.setValue(value) + + +# class PropPushButton(QtWidgets.QPushButton): +# """ +# Displays a node property as a "QPushButton" widget in the PropertiesBin +# widget. +# """ +# +# value_changed = QtCore.Signal(str, object) +# button_clicked = QtCore.Signal(str, object) +# +# def __init__(self, parent=None): +# super(PropPushButton, self).__init__(parent) +# self._name = None +# self.clicked.connect(self.button_clicked.emit) +# +# def set_on_click_func(self, func, node): +# """ +# Sets slot function for the PropPushButton widget. +# +# Args: +# func (function): property slot function. +# node (NodeGraphQt.NodeObject): node object. +# """ +# if not callable(func): +# raise TypeError('var func is not a function.') +# self.clicked.connect(lambda: func(node)) +# +# def get_value(self): +# return +# +# def set_value(self, value): +# return diff --git a/cuegui/NodeGraphQt/errors.py b/cuegui/NodeGraphQt/errors.py new file mode 100644 index 000000000..da23b1221 --- /dev/null +++ b/cuegui/NodeGraphQt/errors.py @@ -0,0 +1,26 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + + +class NodeMenuError(Exception): pass + + +class NodePropertyError(Exception): pass + + +class NodeWidgetError(Exception): pass + + +class NodeCreationError(Exception): pass + + +class NodeDeletionError(Exception): pass + + +class NodeRegistrationError(Exception): pass + + +class PortError(Exception): pass + + +class PortRegistrationError(Exception): pass diff --git a/cuegui/NodeGraphQt/nodes/__init__.py b/cuegui/NodeGraphQt/nodes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cuegui/NodeGraphQt/nodes/backdrop_node.py b/cuegui/NodeGraphQt/nodes/backdrop_node.py new file mode 100644 index 000000000..1aa9d6db7 --- /dev/null +++ b/cuegui/NodeGraphQt/nodes/backdrop_node.py @@ -0,0 +1,141 @@ +#!/usr/bin/python +from NodeGraphQt.base.node import NodeObject +from NodeGraphQt.constants import NodePropWidgetEnum +from NodeGraphQt.qgraphics.node_backdrop import BackdropNodeItem + + +class BackdropNode(NodeObject): + """ + The ``NodeGraphQt.BackdropNode`` class allows other node object to be + nested inside, it's mainly good for grouping nodes together. + + .. inheritance-diagram:: NodeGraphQt.BackdropNode + + .. image:: ../_images/backdrop.png + :width: 250px + + - + """ + + NODE_NAME = 'Backdrop' + + def __init__(self, qgraphics_views=None): + super(BackdropNode, self).__init__(qgraphics_views or BackdropNodeItem) + # override base default color. + self.model.color = (5, 129, 138, 255) + self.create_property('backdrop_text', '', + widget_type=NodePropWidgetEnum.QTEXT_EDIT.value, + tab='Backdrop') + + def on_backdrop_updated(self, update_prop, value=None): + """ + Slot triggered by the "on_backdrop_updated" signal from + the node graph. + + Args: + update_prop (str): update property type. + value (object): update value (optional) + """ + if update_prop == 'sizer_mouse_release': + self.graph.begin_undo('resized "{}"'.format(self.name())) + self.set_property('width', value['width']) + self.set_property('height', value['height']) + self.set_pos(*value['pos']) + self.graph.end_undo() + elif update_prop == 'sizer_double_clicked': + self.graph.begin_undo('"{}" auto resize'.format(self.name())) + self.set_property('width', value['width']) + self.set_property('height', value['height']) + self.set_pos(*value['pos']) + self.graph.end_undo() + + def auto_size(self): + """ + Auto resize the backdrop node to fit around the intersecting nodes. + """ + self.graph.begin_undo('"{}" auto resize'.format(self.name())) + size = self.view.calc_backdrop_size() + self.set_property('width', size['width']) + self.set_property('height', size['height']) + self.set_pos(*size['pos']) + self.graph.end_undo() + + def wrap_nodes(self, nodes): + """ + Set the backdrop size to fit around specified nodes. + + Args: + nodes (list[NodeGraphQt.NodeObject]): list of nodes. + """ + if not nodes: + return + self.graph.begin_undo('"{}" wrap nodes'.format(self.name())) + size = self.view.calc_backdrop_size([n.view for n in nodes]) + self.set_property('width', size['width']) + self.set_property('height', size['height']) + self.set_pos(*size['pos']) + self.graph.end_undo() + + def nodes(self): + """ + Returns nodes wrapped within the backdrop node. + + Returns: + list[NodeGraphQt.BaseNode]: list of node under the backdrop. + """ + node_ids = [n.id for n in self.view.get_nodes()] + return [self.graph.get_node_by_id(nid) for nid in node_ids] + + def set_text(self, text=''): + """ + Sets the text to be displayed in the backdrop node. + + Args: + text (str): text string. + """ + self.set_property('backdrop_text', text) + + def text(self): + """ + Returns the text on the backdrop node. + + Returns: + str: text string. + """ + return self.get_property('backdrop_text') + + def set_size(self, width, height): + """ + Sets the backdrop size. + + Args: + width (float): backdrop width size. + height (float): backdrop height size. + """ + if self.graph: + self.graph.begin_undo('backdrop size') + self.set_property('width', width) + self.set_property('height', height) + self.graph.end_undo() + return + self.view.width, self.view.height = width, height + self.model.width, self.model.height = width, height + + def size(self): + """ + Returns the current size of the node. + + Returns: + tuple: node width, height + """ + self.model.width = self.view.width + self.model.height = self.view.height + return self.model.width, self.model.height + + def inputs(self): + # required function but unused for the backdrop node. + return + + def outputs(self): + # required function but unused for the backdrop node. + return diff --git a/cuegui/NodeGraphQt/nodes/base_node.py b/cuegui/NodeGraphQt/nodes/base_node.py new file mode 100644 index 000000000..2c505b410 --- /dev/null +++ b/cuegui/NodeGraphQt/nodes/base_node.py @@ -0,0 +1,872 @@ +#!/usr/bin/python +from collections import OrderedDict + +from NodeGraphQt.base.commands import NodeVisibleCmd, NodeWidgetVisibleCmd +from NodeGraphQt.base.node import NodeObject +from NodeGraphQt.base.port import Port +from NodeGraphQt.constants import NodePropWidgetEnum, PortTypeEnum +from NodeGraphQt.errors import ( + PortError, + PortRegistrationError, + NodeWidgetError +) +from NodeGraphQt.qgraphics.node_base import NodeItem +from NodeGraphQt.widgets.node_widgets import ( + NodeBaseWidget, + NodeCheckBox, + NodeComboBox, + NodeLineEdit +) + + +class BaseNode(NodeObject): + """ + The ``NodeGraphQt.BaseNode`` class is the base class for nodes that allows + port connections from one node to another. + + .. inheritance-diagram:: NodeGraphQt.BaseNode + + .. image:: ../_images/node.png + :width: 250px + + example snippet: + + .. code-block:: python + :linenos: + + from NodeGraphQt import BaseNode + + class ExampleNode(BaseNode): + + # unique node identifier domain. + __identifier__ = 'io.jchanvfx.github' + + # initial default node name. + NODE_NAME = 'My Node' + + def __init__(self): + super(ExampleNode, self).__init__() + + # create an input port. + self.add_input('in') + + # create an output port. + self.add_output('out') + """ + + NODE_NAME = 'Node' + + def __init__(self, qgraphics_item=None): + super(BaseNode, self).__init__(qgraphics_item or NodeItem) + self._inputs = [] + self._outputs = [] + + def update_model(self): + """ + Update the node model from view. + """ + for name, val in self.view.properties.items(): + if name in ['inputs', 'outputs']: + continue + self.model.set_property(name, val) + + for name, widget in self.view.widgets.items(): + self.model.set_property(name, widget.get_value()) + + def set_property(self, name, value, push_undo=True): + """ + Set the value on the node custom property. + + Args: + name (str): name of the property. + value (object): property data (python built in types). + push_undo (bool): register the command to the undo stack. (default: True) + """ + # prevent signals from causing a infinite loop. + if self.get_property(name) == value: + return + + if name == 'visible': + if self.graph: + undo_cmd = NodeVisibleCmd(self, value) + if push_undo: + self.graph.undo_stack().push(undo_cmd) + else: + undo_cmd.redo() + return + elif name == 'disabled': + # redraw the connected pipes in the scene. + ports = self.view.inputs + self.view.outputs + for port in ports: + for pipe in port.connected_pipes: + pipe.update() + super(BaseNode, self).set_property(name, value, push_undo) + + def set_layout_direction(self, value=0): + """ + Sets the node layout direction to either horizontal or vertical on + the current node only. + + `Implemented in` ``v0.3.0`` + + See Also: + :meth:`NodeGraph.set_layout_direction`, + :meth:`NodeObject.layout_direction` + + + Warnings: + This function does not register to the undo stack. + + Args: + value (int): layout direction mode. + """ + # base logic to update the model and view attributes only. + super(BaseNode, self).set_layout_direction(value) + # redraw the node. + self._view.draw_node() + + def set_icon(self, icon=None): + """ + Set the node icon. + + Args: + icon (str): path to the icon image. + """ + self.set_property('icon', icon) + + def icon(self): + """ + Node icon path. + + Returns: + str: icon image file path. + """ + return self.model.icon + + def widgets(self): + """ + Returns all embedded widgets from this node. + + See Also: + :meth:`BaseNode.get_widget` + + Returns: + dict: embedded node widgets. {``property_name``: ``node_widget``} + """ + return self.view.widgets + + def get_widget(self, name): + """ + Returns the embedded widget associated with the property name. + + See Also: + :meth:`BaseNode.add_combo_menu`, + :meth:`BaseNode.add_text_input`, + :meth:`BaseNode.add_checkbox`, + + Args: + name (str): node property name. + + Returns: + NodeBaseWidget: embedded node widget. + """ + return self.view.widgets.get(name) + + def add_custom_widget(self, widget, widget_type=None, tab=None): + """ + Add a custom node widget into the node. + + see example :ref:`Embedding Custom Widgets`. + + Note: + The ``value_changed`` signal from the added node widget is wired + up to the :meth:`NodeObject.set_property` function. + + Args: + widget (NodeBaseWidget): node widget class object. + widget_type: widget flag to display in the + :class:`NodeGraphQt.PropertiesBinWidget` + (default: :attr:`NodeGraphQt.constants.NodePropWidgetEnum.HIDDEN`). + tab (str): name of the widget tab to display in. + """ + if not isinstance(widget, NodeBaseWidget): + raise NodeWidgetError( + '\'widget\' must be an instance of a NodeBaseWidget') + + widget_type = widget_type or NodePropWidgetEnum.HIDDEN.value + self.create_property(widget.get_name(), + widget.get_value(), + widget_type=widget_type, + tab=tab) + widget.value_changed.connect(lambda k, v: self.set_property(k, v)) + widget._node = self + self.view.add_widget(widget) + #: redraw node to address calls outside the "__init__" func. + self.view.draw_node() + + def add_combo_menu(self, name, label='', items=None, tooltip=None, + tab=None): + """ + Creates a custom property with the :meth:`NodeObject.create_property` + function and embeds a :class:`PySide2.QtWidgets.QComboBox` widget + into the node. + + Note: + The ``value_changed`` signal from the added node widget is wired + up to the :meth:`NodeObject.set_property` function. + + Args: + name (str): name for the custom property. + label (str): label to be displayed. + items (list[str]): items to be added into the menu. + tooltip (str): widget tooltip. + tab (str): name of the widget tab to display in. + """ + self.create_property( + name, + value=items[0] if items else None, + items=items or [], + widget_type=NodePropWidgetEnum.QCOMBO_BOX.value, + widget_tooltip=tooltip, + tab=tab + ) + widget = NodeComboBox(self.view, name, label, items) + widget.setToolTip(tooltip or '') + widget.value_changed.connect(lambda k, v: self.set_property(k, v)) + self.view.add_widget(widget) + #: redraw node to address calls outside the "__init__" func. + self.view.draw_node() + + def add_text_input(self, name, label='', text='', placeholder_text='', + tooltip=None, tab=None): + """ + Creates a custom property with the :meth:`NodeObject.create_property` + function and embeds a :class:`PySide2.QtWidgets.QLineEdit` widget + into the node. + + Note: + The ``value_changed`` signal from the added node widget is wired + up to the :meth:`NodeObject.set_property` function. + + Args: + name (str): name for the custom property. + label (str): label to be displayed. + text (str): pre-filled text. + placeholder_text (str): placeholder text. + tooltip (str): widget tooltip. + tab (str): name of the widget tab to display in. + """ + self.create_property( + name, + value=text, + widget_type=NodePropWidgetEnum.QLINE_EDIT.value, + widget_tooltip=tooltip, + tab=tab + ) + widget = NodeLineEdit(self.view, name, label, text, placeholder_text) + widget.setToolTip(tooltip or '') + widget.value_changed.connect(lambda k, v: self.set_property(k, v)) + self.view.add_widget(widget) + #: redraw node to address calls outside the "__init__" func. + self.view.draw_node() + + def add_checkbox(self, name, label='', text='', state=False, tooltip=None, + tab=None): + """ + Creates a custom property with the :meth:`NodeObject.create_property` + function and embeds a :class:`PySide2.QtWidgets.QCheckBox` widget + into the node. + + Note: + The ``value_changed`` signal from the added node widget is wired + up to the :meth:`NodeObject.set_property` function. + + Args: + name (str): name for the custom property. + label (str): label to be displayed. + text (str): checkbox text. + state (bool): pre-check. + tooltip (str): widget tooltip. + tab (str): name of the widget tab to display in. + """ + self.create_property( + name, + value=state, + widget_type=NodePropWidgetEnum.QCHECK_BOX.value, + widget_tooltip=tooltip, + tab=tab + ) + widget = NodeCheckBox(self.view, name, label, text, state) + widget.setToolTip(tooltip or '') + widget.value_changed.connect(lambda k, v: self.set_property(k, v)) + self.view.add_widget(widget) + #: redraw node to address calls outside the "__init__" func. + self.view.draw_node() + + def hide_widget(self, name, push_undo=True): + """ + Hide an embedded node widget. + + Args: + name (str): node property name for the widget. + push_undo (bool): register the command to the undo stack. (default: True) + + See Also: + :meth:`BaseNode.add_custom_widget`, + :meth:`BaseNode.show_widget`, + :meth:`BaseNode.get_widget` + """ + if not self.view.has_widget(name): + return + undo_cmd = NodeWidgetVisibleCmd(self, name, visible=False) + if push_undo: + self.graph.undo_stack().push(undo_cmd) + else: + undo_cmd.redo() + + def show_widget(self, name, push_undo=True): + """ + Show an embedded node widget. + + Args: + name (str): node property name for the widget. + push_undo (bool): register the command to the undo stack. (default: True) + + See Also: + :meth:`BaseNode.add_custom_widget`, + :meth:`BaseNode.hide_widget`, + :meth:`BaseNode.get_widget` + """ + if not self.view.has_widget(name): + return + undo_cmd = NodeWidgetVisibleCmd(self, name, visible=True) + if push_undo: + self.graph.undo_stack().push(undo_cmd) + else: + undo_cmd.redo() + + def add_input(self, name='input', multi_input=False, display_name=True, + color=None, locked=False, painter_func=None): + """ + Add input :class:`Port` to node. + + Warnings: + Undo is NOT supported for this function. + + Args: + name (str): name for the input port. + multi_input (bool): allow port to have more than one connection. + display_name (bool): display the port name on the node. + color (tuple): initial port color (r, g, b) ``0-255``. + locked (bool): locked state see :meth:`Port.set_locked` + painter_func (function or None): custom function to override the drawing + of the port shape see example: :ref:`Creating Custom Shapes` + + Returns: + NodeGraphQt.Port: the created port object. + """ + if name in self.inputs().keys(): + raise PortRegistrationError( + 'port name "{}" already registered.'.format(name)) + + port_args = [name, multi_input, display_name, locked] + if painter_func and callable(painter_func): + port_args.append(painter_func) + view = self.view.add_input(*port_args) + + if color: + view.color = color + view.border_color = [min([255, max([0, i + 80])]) for i in color] + + port = Port(self, view) + port.model.type_ = PortTypeEnum.IN.value + port.model.name = name + port.model.display_name = display_name + port.model.multi_connection = multi_input + port.model.locked = locked + self._inputs.append(port) + self.model.inputs[port.name()] = port.model + return port + + def add_output(self, name='output', multi_output=True, display_name=True, + color=None, locked=False, painter_func=None): + """ + Add output :class:`Port` to node. + + Warnings: + Undo is NOT supported for this function. + + Args: + name (str): name for the output port. + multi_output (bool): allow port to have more than one connection. + display_name (bool): display the port name on the node. + color (tuple): initial port color (r, g, b) ``0-255``. + locked (bool): locked state see :meth:`Port.set_locked` + painter_func (function or None): custom function to override the drawing + of the port shape see example: :ref:`Creating Custom Shapes` + + Returns: + NodeGraphQt.Port: the created port object. + """ + if name in self.outputs().keys(): + raise PortRegistrationError( + 'port name "{}" already registered.'.format(name)) + + port_args = [name, multi_output, display_name, locked] + if painter_func and callable(painter_func): + port_args.append(painter_func) + view = self.view.add_output(*port_args) + + if color: + view.color = color + view.border_color = [min([255, max([0, i + 80])]) for i in color] + port = Port(self, view) + port.model.type_ = PortTypeEnum.OUT.value + port.model.name = name + port.model.display_name = display_name + port.model.multi_connection = multi_output + port.model.locked = locked + self._outputs.append(port) + self.model.outputs[port.name()] = port.model + return port + + def get_input(self, port): + """ + Get input port by the name or index. + + Args: + port (str or int): port name or index. + + Returns: + NodeGraphQt.Port: node port. + """ + if type(port) is int: + if port < len(self._inputs): + return self._inputs[port] + elif type(port) is str: + return self.inputs().get(port, None) + + def get_output(self, port): + """ + Get output port by the name or index. + + Args: + port (str or int): port name or index. + + Returns: + NodeGraphQt.Port: node port. + """ + if type(port) is int: + if port < len(self._outputs): + return self._outputs[port] + elif type(port) is str: + return self.outputs().get(port, None) + + def delete_input(self, port): + """ + Delete input port. + + Warnings: + Undo is NOT supported for this function. + + You can only delete ports if :meth:`BaseNode.port_deletion_allowed` + returns ``True`` otherwise a port error is raised see also + :meth:`BaseNode.set_port_deletion_allowed`. + + Args: + port (str or int): port name or index. + """ + if type(port) in [int, str]: + port = self.get_input(port) + if port is None: + return + if not self.port_deletion_allowed(): + raise PortError( + 'Port "{}" can\'t be deleted on this node because ' + '"ports_removable" is not enabled.'.format(port.name())) + if port.locked(): + raise PortError('Error: Can\'t delete a port that is locked!') + self._inputs.remove(port) + self._model.inputs.pop(port.name()) + self._view.delete_input(port.view) + port.model.node = None + self._view.draw_node() + + def delete_output(self, port): + """ + Delete output port. + + Warnings: + Undo is NOT supported for this function. + + You can only delete ports if :meth:`BaseNode.port_deletion_allowed` + returns ``True`` otherwise a port error is raised see also + :meth:`BaseNode.set_port_deletion_allowed`. + + Args: + port (str or int): port name or index. + """ + if type(port) in [int, str]: + port = self.get_output(port) + if port is None: + return + if not self.port_deletion_allowed(): + raise PortError( + 'Port "{}" can\'t be deleted on this node because ' + '"ports_removable" is not enabled.'.format(port.name())) + if port.locked(): + raise PortError('Error: Can\'t delete a port that is locked!') + self._outputs.remove(port) + self._model.outputs.pop(port.name()) + self._view.delete_output(port.view) + port.model.node = None + self._view.draw_node() + + def set_port_deletion_allowed(self, mode=False): + """ + Allow ports to be removable on this node. + + See Also: + :meth:`BaseNode.port_deletion_allowed` and + :meth:`BaseNode.set_ports` + + Args: + mode (bool): true to allow. + """ + self.model.port_deletion_allowed = mode + + def port_deletion_allowed(self): + """ + Return true if ports can be deleted on this node. + + See Also: + :meth:`BaseNode.set_port_deletion_allowed` + + Returns: + bool: true if ports can be deleted. + """ + return self.model.port_deletion_allowed + + def set_ports(self, port_data): + """ + Create node input and output ports from serialized port data. + + Warnings: + You can only use this function if the node has + :meth:`BaseNode.port_deletion_allowed` is `True` + see :meth:`BaseNode.set_port_deletion_allowed` + + Hint: + example snippet of port data. + + .. highlight:: python + .. code-block:: python + + { + 'input_ports': + [{ + 'name': 'input', + 'multi_connection': True, + 'display_name': 'Input', + 'locked': False + }], + 'output_ports': + [{ + 'name': 'output', + 'multi_connection': True, + 'display_name': 'Output', + 'locked': False + }] + } + + Args: + port_data(dict): port data. + """ + if not self.port_deletion_allowed(): + raise PortError( + 'Ports cannot be set on this node because ' + '"set_port_deletion_allowed" is not enabled on this node.') + + for port in self._inputs: + self._view.delete_input(port.view) + port.model.node = None + for port in self._outputs: + self._view.delete_output(port.view) + port.model.node = None + self._inputs = [] + self._outputs = [] + self._model.outputs = {} + self._model.inputs = {} + + [self.add_input(name=port['name'], + multi_input=port['multi_connection'], + display_name=port['display_name'], + locked=port.get('locked') or False) + for port in port_data['input_ports']] + [self.add_output(name=port['name'], + multi_output=port['multi_connection'], + display_name=port['display_name'], + locked=port.get('locked') or False) + for port in port_data['output_ports']] + self._view.draw_node() + + def inputs(self): + """ + Returns all the input ports from the node. + + Returns: + dict: {: } + """ + return {p.name(): p for p in self._inputs} + + def input_ports(self): + """ + Return all input ports. + + Returns: + list[NodeGraphQt.Port]: node input ports. + """ + return self._inputs + + def outputs(self): + """ + Returns all the output ports from the node. + + Returns: + dict: {: } + """ + return {p.name(): p for p in self._outputs} + + def output_ports(self): + """ + Return all output ports. + + Returns: + list[NodeGraphQt.Port]: node output ports. + """ + return self._outputs + + def input(self, index): + """ + Return the input port with the matching index. + + Args: + index (int): index of the input port. + + Returns: + NodeGraphQt.Port: port object. + """ + return self._inputs[index] + + def set_input(self, index, port): + """ + Creates a connection pipe to the targeted output :class:`Port`. + + Args: + index (int): index of the port. + port (NodeGraphQt.Port): port object. + """ + src_port = self.input(index) + src_port.connect_to(port) + + def output(self, index): + """ + Return the output port with the matching index. + + Args: + index (int): index of the output port. + + Returns: + NodeGraphQt.Port: port object. + """ + return self._outputs[index] + + def set_output(self, index, port): + """ + Creates a connection pipe to the targeted input :class:`Port`. + + Args: + index (int): index of the port. + port (NodeGraphQt.Port): port object. + """ + src_port = self.output(index) + src_port.connect_to(port) + + def connected_input_nodes(self): + """ + Returns all nodes connected from the input ports. + + Returns: + dict: {: } + """ + nodes = OrderedDict() + for p in self.input_ports(): + nodes[p] = [cp.node() for cp in p.connected_ports()] + return nodes + + def connected_output_nodes(self): + """ + Returns all nodes connected from the output ports. + + Returns: + dict: {: } + """ + nodes = OrderedDict() + for p in self.output_ports(): + nodes[p] = [cp.node() for cp in p.connected_ports()] + return nodes + + def add_accept_port_type(self, port, port_type_data): + """ + Add an accept constrain to a specified node port. + + Once a constraint has been added only ports of that type specified will + be allowed a pipe connection. + + port type data example + + .. highlight:: python + .. code-block:: python + + { + 'port_name': 'foo' + 'port_type': PortTypeEnum.IN.value + 'node_type': 'io.github.jchanvfx.NodeClass' + } + + See Also: + :meth:`NodeGraphQt.BaseNode.accepted_port_types` + + Args: + port (NodeGraphQt.Port): port to assign constrain to. + port_type_data (dict): port type data to accept a connection + """ + node_ports = self._inputs + self._outputs + if port not in node_ports: + raise PortError('Node does not contain port: "{}"'.format(port)) + + self._model.add_port_accept_connection_type( + port_name=port.name(), + port_type=port.type_(), + node_type=self.type_, + accept_pname=port_type_data['port_name'], + accept_ptype=port_type_data['port_type'], + accept_ntype=port_type_data['node_type'] + ) + + def accepted_port_types(self, port): + """ + Returns a dictionary of connection constrains of the port types + that allow for a pipe connection to this node. + + Args: + port (NodeGraphQt.Port): port object. + + Returns: + dict: {: {: []}} + """ + ports = self._inputs + self._outputs + if port not in ports: + raise PortError('Node does not contain port "{}"'.format(port)) + + accepted_types = self.graph.model.port_accept_connection_types( + node_type=self.type_, + port_type=port.type_(), + port_name=port.name() + ) + return accepted_types + + def add_reject_port_type(self, port, port_type_data): + """ + Add a reject constrain to a specified node port. + + Once a constraint has been added only ports of that type specified will + NOT be allowed a pipe connection. + + port type data example + + .. highlight:: python + .. code-block:: python + + { + 'port_name': 'foo' + 'port_type': PortTypeEnum.IN.value + 'node_type': 'io.github.jchanvfx.NodeClass' + } + + See Also: + :meth:`NodeGraphQt.Port.rejected_port_types` + + Args: + port (NodeGraphQt.Port): port to assign constrain to. + port_type_data (dict): port type data to reject a connection + """ + node_ports = self._inputs + self._outputs + if port not in node_ports: + raise PortError('Node does not contain port: "{}"'.format(port)) + + self._model.add_port_reject_connection_type( + port_name=port.name(), + port_type=port.type_(), + node_type=self.type_, + reject_pname=port_type_data['port_name'], + reject_ptype=port_type_data['port_type'], + reject_ntype=port_type_data['node_type'] + ) + + def rejected_port_types(self, port): + """ + Returns a dictionary of connection constrains of the port types + that are NOT allowed for a pipe connection to this node. + + Args: + port (NodeGraphQt.Port): port object. + + Returns: + dict: {: {: []}} + """ + ports = self._inputs + self._outputs + if port not in ports: + raise PortError('Node does not contain port "{}"'.format(port)) + + rejected_types = self.graph.model.port_reject_connection_types( + node_type=self.type_, + port_type=port.type_(), + port_name=port.name() + ) + return rejected_types + + def on_input_connected(self, in_port, out_port): + """ + Callback triggered when a new pipe connection is made. + + *The default of this function does nothing re-implement if you require + logic to run for this event.* + + Note: + to work with undo & redo for this method re-implement + :meth:`BaseNode.on_input_disconnected` with the reverse logic. + + Args: + in_port (NodeGraphQt.Port): source input port from this node. + out_port (NodeGraphQt.Port): output port that connected to this node. + """ + return + + def on_input_disconnected(self, in_port, out_port): + """ + Callback triggered when a pipe connection has been disconnected + from a INPUT port. + + *The default of this function does nothing re-implement if you require + logic to run for this event.* + + Note: + to work with undo & redo for this method re-implement + :meth:`BaseNode.on_input_connected` with the reverse logic. + + Args: + in_port (NodeGraphQt.Port): source input port from this node. + out_port (NodeGraphQt.Port): output port that was disconnected. + """ + return diff --git a/cuegui/NodeGraphQt/nodes/base_node_circle.py b/cuegui/NodeGraphQt/nodes/base_node_circle.py new file mode 100644 index 000000000..6aeb6e0d4 --- /dev/null +++ b/cuegui/NodeGraphQt/nodes/base_node_circle.py @@ -0,0 +1,46 @@ +#!/usr/bin/python +from NodeGraphQt.nodes.base_node import BaseNode +from NodeGraphQt.qgraphics.node_circle import CircleNodeItem + + +class BaseNodeCircle(BaseNode): + """ + `Implemented in` ``v0.5.2`` + + The ``NodeGraphQt.BaseNodeCircle`` is pretty much the same class as the + :class:`NodeGraphQt.BaseNode` except with a different design. + + .. inheritance-diagram:: NodeGraphQt.BaseNodeCircle + + .. image:: ../_images/node_circle.png + :width: 250px + + example snippet: + + .. code-block:: python + :linenos: + + from NodeGraphQt import BaseNodeCircle + + class ExampleNode(BaseNodeCircle): + + # unique node identifier domain. + __identifier__ = 'io.jchanvfx.github' + + # initial default node name. + NODE_NAME = 'My Node' + + def __init__(self): + super(ExampleNode, self).__init__() + + # create an input port. + self.add_input('in') + + # create an output port. + self.add_output('out') + """ + + NODE_NAME = 'Circle Node' + + def __init__(self, qgraphics_item=None): + super(BaseNodeCircle, self).__init__(qgraphics_item or CircleNodeItem) diff --git a/cuegui/NodeGraphQt/nodes/group_node.py b/cuegui/NodeGraphQt/nodes/group_node.py new file mode 100644 index 000000000..8e14e4be8 --- /dev/null +++ b/cuegui/NodeGraphQt/nodes/group_node.py @@ -0,0 +1,176 @@ +#!/usr/bin/python +from NodeGraphQt.nodes.base_node import BaseNode +from NodeGraphQt.nodes.port_node import PortInputNode, PortOutputNode +from NodeGraphQt.qgraphics.node_group import GroupNodeItem + + +class GroupNode(BaseNode): + """ + `Implemented in` ``v0.2.0`` + + The ``NodeGraphQt.GroupNode`` class extends from the :class:`NodeGraphQt.BaseNode` + class with the ability to nest other nodes inside of it. + + .. inheritance-diagram:: NodeGraphQt.GroupNode + + .. image:: ../_images/group_node.png + :width: 250px + + - + """ + + NODE_NAME = 'Group' + + def __init__(self, qgraphics_item=None): + super(GroupNode, self).__init__(qgraphics_item or GroupNodeItem) + self._input_port_nodes = {} + self._output_port_nodes = {} + + @property + def is_expanded(self): + """ + Returns if the group node is expanded or collapsed. + + Returns: + bool: true if the node is expanded. + """ + if not self.graph: + return False + return bool(self.id in self.graph.sub_graphs) + + def get_sub_graph(self): + """ + Returns the sub graph controller to the group node if initialized + or returns None. + + Returns: + SubGraph: sub graph controller. + """ + return self.graph.sub_graphs.get(self.id) + + def get_sub_graph_session(self): + """ + Returns the serialized sub graph session. + + Returns: + dict: serialized sub graph session. + """ + return self.model.subgraph_session + + def set_sub_graph_session(self, serialized_session): + """ + Sets the sub graph session data to the group node. + + Args: + serialized_session (dict): serialized session. + """ + serialized_session = serialized_session or {} + self.model.subgraph_session = serialized_session + + def expand(self): + """ + Expand the group node session. + + See Also: + :meth:`NodeGraph.expand_group_node`, + :meth:`SubGraph.expand_group_node`. + + Returns: + SubGraph: node graph used to manage the nodes expaneded session. + """ + sub_graph = self.graph.expand_group_node(self) + return sub_graph + + def collapse(self): + """ + Collapse the group node session it's expanded child sub graphs. + + See Also: + :meth:`NodeGraph.collapse_group_node`, + :meth:`SubGraph.collapse_group_node`. + """ + self.graph.collapse_group_node(self) + + def set_name(self, name=''): + super(GroupNode, self).set_name(name) + # update the tab bar and navigation labels. + sub_graph = self.get_sub_graph() + if sub_graph: + nav_widget = sub_graph.navigation_widget + nav_widget.update_label_item(self.name(), self.id) + + if sub_graph.parent_graph.is_root: + root_graph = sub_graph.parent_graph + tab_bar = root_graph.widget.tabBar() + for idx in range(tab_bar.count()): + if tab_bar.tabToolTip(idx) == self.id: + tab_bar.setTabText(idx, self.name()) + break + + def add_input(self, name='input', multi_input=False, display_name=True, + color=None, locked=False, painter_func=None): + port = super(GroupNode, self).add_input( + name=name, + multi_input=multi_input, + display_name=display_name, + color=color, + locked=locked, + painter_func=painter_func + ) + if self.is_expanded: + input_node = PortInputNode(parent_port=port) + input_node.NODE_NAME = port.name() + input_node.model.set_property('name', port.name()) + input_node.add_output(port.name()) + sub_graph = self.get_sub_graph() + sub_graph.add_node(input_node, selected=False, push_undo=False) + + return port + + def add_output(self, name='output', multi_output=True, display_name=True, + color=None, locked=False, painter_func=None): + port = super(GroupNode, self).add_output( + name=name, + multi_output=multi_output, + display_name=display_name, + color=color, + locked=locked, + painter_func=painter_func + ) + if self.is_expanded: + output_port = PortOutputNode(parent_port=port) + output_port.NODE_NAME = port.name() + output_port.model.set_property('name', port.name()) + output_port.add_input(port.name()) + sub_graph = self.get_sub_graph() + sub_graph.add_node(output_port, selected=False, push_undo=False) + + return port + + def delete_input(self, port): + if type(port) in [int, str]: + port = self.get_input(port) + if port is None: + return + + if self.is_expanded: + sub_graph = self.get_sub_graph() + port_node = sub_graph.get_node_by_port(port) + if port_node: + sub_graph.remove_node(port_node, push_undo=False) + + super(GroupNode, self).delete_input(port) + + def delete_output(self, port): + if type(port) in [int, str]: + port = self.get_output(port) + if port is None: + return + + if self.is_expanded: + sub_graph = self.get_sub_graph() + port_node = sub_graph.get_node_by_port(port) + if port_node: + sub_graph.remove_node(port_node, push_undo=False) + + super(GroupNode, self).delete_output(port) diff --git a/cuegui/NodeGraphQt/nodes/port_node.py b/cuegui/NodeGraphQt/nodes/port_node.py new file mode 100644 index 000000000..7fc4e9196 --- /dev/null +++ b/cuegui/NodeGraphQt/nodes/port_node.py @@ -0,0 +1,135 @@ +#!/usr/bin/python +from NodeGraphQt.errors import PortRegistrationError +from NodeGraphQt.nodes.base_node import BaseNode +from NodeGraphQt.qgraphics.node_port_in import PortInputNodeItem +from NodeGraphQt.qgraphics.node_port_out import PortOutputNodeItem + + +class PortInputNode(BaseNode): + """ + The ``PortInputNode`` is the node that represents a input port from a + :class:`NodeGraphQt.GroupNode` when expanded in a + :class:`NodeGraphQt.SubGraph`. + + .. inheritance-diagram:: NodeGraphQt.nodes.port_node.PortInputNode + :parts: 1 + + .. image:: ../_images/port_in_node.png + :width: 150px + + - + """ + + NODE_NAME = 'InputPort' + + def __init__(self, qgraphics_item=None, parent_port=None): + super(PortInputNode, self).__init__(qgraphics_item or PortInputNodeItem) + self._parent_port = parent_port + + @property + def parent_port(self): + """ + The parent group node port representing this node. + + Returns: + NodeGraphQt.Port: port object. + """ + return self._parent_port + + def add_input(self, name='input', multi_input=False, display_name=True, + color=None, locked=False, painter_func=None): + """ + Warnings: + This is not available for the ``PortInputNode`` class. + """ + raise PortRegistrationError( + '"{}.add_input()" is not available for {}.' + .format(self.__class__.__name__, self) + ) + + def add_output(self, name='output', multi_output=True, display_name=True, + color=None, locked=False, painter_func=None): + """ + Warnings: + This function is called by :meth:`NodeGraphQt.SubGraph.expand_group_node` + and is not available for the ``PortInputNode`` class. + """ + if self._outputs: + raise PortRegistrationError( + '"{}.add_output()" only ONE output is allowed for this node.' + .format(self.__class__.__name__, self) + ) + super(PortInputNode, self).add_output( + name=name, + multi_output=multi_output, + display_name=False, + color=color, + locked=locked, + painter_func=None + ) + + +class PortOutputNode(BaseNode): + """ + The ``PortOutputNode`` is the node that represents a output port from a + :class:`NodeGraphQt.GroupNode` when expanded in a + :class:`NodeGraphQt.SubGraph`. + + .. inheritance-diagram:: NodeGraphQt.nodes.port_node.PortOutputNode + :parts: 1 + + .. image:: ../_images/port_out_node.png + :width: 150px + + - + """ + + NODE_NAME = 'OutputPort' + + def __init__(self, qgraphics_item=None, parent_port=None): + super(PortOutputNode, self).__init__( + qgraphics_item or PortOutputNodeItem + ) + self._parent_port = parent_port + + @property + def parent_port(self): + """ + The parent group node port representing this node. + + Returns: + NodeGraphQt.Port: port object. + """ + return self._parent_port + + def add_input(self, name='input', multi_input=False, display_name=True, + color=None, locked=False, painter_func=None): + """ + Warnings: + This function is called by :meth:`NodeGraphQt.SubGraph.expand_group_node` + and is not available for the ``PortOutputNode`` class. + """ + if self._inputs: + raise PortRegistrationError( + '"{}.add_input()" only ONE input is allowed for this node.' + .format(self.__class__.__name__, self) + ) + super(PortOutputNode, self).add_input( + name=name, + multi_input=multi_input, + display_name=False, + color=color, + locked=locked, + painter_func=None + ) + + def add_output(self, name='output', multi_output=True, display_name=True, + color=None, locked=False, painter_func=None): + """ + Warnings: + This is not available for the ``PortOutputNode`` class. + """ + raise PortRegistrationError( + '"{}.add_output()" is not available for {}.' + .format(self.__class__.__name__, self) + ) diff --git a/cuegui/NodeGraphQt/pkg_info.py b/cuegui/NodeGraphQt/pkg_info.py new file mode 100644 index 000000000..cfb011d62 --- /dev/null +++ b/cuegui/NodeGraphQt/pkg_info.py @@ -0,0 +1,10 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +__version__ = '0.6.36' +__status__ = 'Work in Progress' +__license__ = 'MIT' + +__author__ = 'Johnny Chan' + +__module_name__ = 'NodeGraphQt' +__url__ = 'https://github.com/jchanvfx/NodeGraphQt' diff --git a/cuegui/NodeGraphQt/qgraphics/__init__.py b/cuegui/NodeGraphQt/qgraphics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cuegui/NodeGraphQt/qgraphics/node_abstract.py b/cuegui/NodeGraphQt/qgraphics/node_abstract.py new file mode 100644 index 000000000..4529fe458 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_abstract.py @@ -0,0 +1,261 @@ +#!/usr/bin/python +from qtpy import QtCore, QtWidgets + +from NodeGraphQt.constants import ( + Z_VAL_NODE, + ITEM_CACHE_MODE, + LayoutDirectionEnum, + NodeEnum +) + + +class AbstractNodeItem(QtWidgets.QGraphicsItem): + """ + The base class of all node qgraphics item. + """ + + def __init__(self, name='node', parent=None): + super(AbstractNodeItem, self).__init__(parent) + self.setFlags(self.GraphicsItemFlag.ItemIsSelectable | self.GraphicsItemFlag.ItemIsMovable) + self.setCacheMode(ITEM_CACHE_MODE) + self.setZValue(Z_VAL_NODE) + self._properties = { + 'id': None, + 'name': name.strip(), + 'color': (13, 18, 23, 255), + 'border_color': (46, 57, 66, 255), + 'text_color': (255, 255, 255, 180), + 'type_': 'AbstractBaseNode', + 'selected': False, + 'disabled': False, + 'visible': False, + 'layout_direction': LayoutDirectionEnum.HORIZONTAL.value, + } + self._width = NodeEnum.WIDTH.value + self._height = NodeEnum.HEIGHT.value + + def __repr__(self): + return '{}.{}(\'{}\')'.format( + self.__module__, self.__class__.__name__, self.name) + + def boundingRect(self): + return QtCore.QRectF(0.0, 0.0, self._width, self._height) + + def mousePressEvent(self, event): + """ + Re-implemented to update "self._properties['selected']" attribute. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. + """ + self._properties['selected'] = True + super(AbstractNodeItem, self).mousePressEvent(event) + + def setSelected(self, selected): + self._properties['selected'] = selected + super(AbstractNodeItem, self).setSelected(selected) + + def draw_node(self): + """ + Re-draw the node item in the scene with proper + calculated size and widgets aligned. + + (this is called from the builtin custom widgets.) + """ + return + + def pre_init(self, viewer, pos=None): + """ + Called before node has been added into the scene. + + Args: + viewer (NodeGraphQt.widgets.viewer.NodeViewer): main viewer. + pos (tuple): the cursor pos if node is called with tab search. + """ + return + + def post_init(self, viewer, pos=None): + """ + Called after node has been added into the scene. + + Args: + viewer (NodeGraphQt.widgets.viewer.NodeViewer): main viewer + pos (tuple): the cursor pos if node is called with tab search. + """ + return + + @property + def id(self): + return self._properties['id'] + + @id.setter + def id(self, unique_id=''): + self._properties['id'] = unique_id + + @property + def type_(self): + return self._properties['type_'] + + @type_.setter + def type_(self, node_type='NODE'): + self._properties['type_'] = node_type + + @property + def layout_direction(self): + return self._properties['layout_direction'] + + @layout_direction.setter + def layout_direction(self, value=0): + self._properties['layout_direction'] = value + + @property + def size(self): + return self._width, self._height + + @property + def width(self): + return self._width + + @width.setter + def width(self, width=0.0): + self._width = width + + @property + def height(self): + return self._height + + @height.setter + def height(self, height=0.0): + self._height = height + + @property + def color(self): + return self._properties['color'] + + @color.setter + def color(self, color=(0, 0, 0, 255)): + self._properties['color'] = color + + @property + def text_color(self): + return self._properties['text_color'] + + @text_color.setter + def text_color(self, color=(100, 100, 100, 255)): + self._properties['text_color'] = color + + @property + def border_color(self): + return self._properties['border_color'] + + @border_color.setter + def border_color(self, color=(0, 0, 0, 255)): + self._properties['border_color'] = color + + @property + def disabled(self): + return self._properties['disabled'] + + @disabled.setter + def disabled(self, state=False): + self._properties['disabled'] = state + + @property + def selected(self): + if self._properties['selected'] != self.isSelected(): + self._properties['selected'] = self.isSelected() + return self._properties['selected'] + + @selected.setter + def selected(self, selected=False): + self.setSelected(selected) + + @property + def visible(self): + return self._properties['visible'] + + @visible.setter + def visible(self, visible=False): + self._properties['visible'] = visible + self.setVisible(visible) + + @property + def xy_pos(self): + """ + return the item scene postion. + ("node.pos" conflicted with "QGraphicsItem.pos()" + so it was refactored to "xy_pos".) + + Returns: + list[float]: x, y scene position. + """ + return [float(self.scenePos().x()), float(self.scenePos().y())] + + @xy_pos.setter + def xy_pos(self, pos=None): + """ + set the item scene postion. + ("node.pos" conflicted with "QGraphicsItem.pos()" + so it was refactored to "xy_pos".) + + Args: + pos (list[float]): x, y scene position. + """ + pos = pos or [0.0, 0.0] + self.setPos(pos[0], pos[1]) + + @property + def name(self): + return self._properties['name'] + + @name.setter + def name(self, name=''): + self._properties['name'] = name + self.setToolTip('node: {}'.format(name)) + + @property + def properties(self): + """ + return the node view attributes. + + Returns: + dict: {property_name: property_value} + """ + props = {'width': self.width, + 'height': self.height, + 'pos': self.xy_pos} + props.update(self._properties) + return props + + def viewer(self): + """ + return the main viewer. + + Returns: + NodeGraphQt.widgets.viewer.NodeViewer: viewer object. + """ + if self.scene(): + return self.scene().viewer() + + def delete(self): + """ + remove node view from the scene. + """ + if self.scene(): + self.scene().removeItem(self) + + def from_dict(self, node_dict): + """ + set the node view attributes from the dictionary. + + Args: + node_dict (dict): serialized node dict. + """ + node_attrs = list(self._properties.keys()) + ['width', 'height', 'pos'] + for name, value in node_dict.items(): + if name in node_attrs: + # "node.pos" conflicted with "QGraphicsItem.pos()" + # so it's refactored to "xy_pos". + if name == 'pos': + name = 'xy_pos' + setattr(self, name, value) diff --git a/cuegui/NodeGraphQt/qgraphics/node_backdrop.py b/cuegui/NodeGraphQt/qgraphics/node_backdrop.py new file mode 100644 index 000000000..d5141b332 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_backdrop.py @@ -0,0 +1,311 @@ +#!/usr/bin/python +from qtpy import QtGui, QtCore, QtWidgets + +from NodeGraphQt.constants import Z_VAL_BACKDROP, NodeEnum +from NodeGraphQt.qgraphics.node_abstract import AbstractNodeItem +from NodeGraphQt.qgraphics.pipe import PipeItem +from NodeGraphQt.qgraphics.port import PortItem + + +class BackdropSizer(QtWidgets.QGraphicsItem): + """ + Sizer item for resizing a backdrop item. + + Args: + parent (BackdropNodeItem): the parent node item. + size (float): sizer size. + """ + + def __init__(self, parent=None, size=6.0): + super(BackdropSizer, self).__init__(parent) + self.setFlag(self.GraphicsItemFlag.ItemIsSelectable, True) + self.setFlag(self.GraphicsItemFlag.ItemIsMovable, True) + self.setFlag(self.GraphicsItemFlag.ItemSendsScenePositionChanges, True) + self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.SizeFDiagCursor)) + self.setToolTip('double-click auto resize') + self._size = size + + @property + def size(self): + return self._size + + def set_pos(self, x, y): + x -= self._size + y -= self._size + self.setPos(x, y) + + def boundingRect(self): + return QtCore.QRectF(0.5, 0.5, self._size, self._size) + + def itemChange(self, change, value): + if change == self.GraphicsItemChange.ItemPositionChange: + item = self.parentItem() + mx, my = item.minimum_size + x = mx if value.x() < mx else value.x() + y = my if value.y() < my else value.y() + value = QtCore.QPointF(x, y) + item.on_sizer_pos_changed(value) + return value + return super(BackdropSizer, self).itemChange(change, value) + + def mouseDoubleClickEvent(self, event): + item = self.parentItem() + item.on_sizer_double_clicked() + super(BackdropSizer, self).mouseDoubleClickEvent(event) + + def mousePressEvent(self, event): + self.__prev_xy = (self.pos().x(), self.pos().y()) + super(BackdropSizer, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + current_xy = (self.pos().x(), self.pos().y()) + if current_xy != self.__prev_xy: + item = self.parentItem() + item.on_sizer_pos_mouse_release() + del self.__prev_xy + super(BackdropSizer, self).mouseReleaseEvent(event) + + def paint(self, painter, option, widget): + """ + Draws the backdrop sizer in the bottom right corner. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + painter.save() + + margin = 1.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + item = self.parentItem() + if item and item.selected: + color = QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value) + else: + color = QtGui.QColor(*item.color) + color = color.darker(110) + path = QtGui.QPainterPath() + path.moveTo(rect.topRight()) + path.lineTo(rect.bottomRight()) + path.lineTo(rect.bottomLeft()) + painter.setBrush(color) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.fillPath(path, painter.brush()) + + painter.restore() + + +class BackdropNodeItem(AbstractNodeItem): + """ + Base Backdrop item. + + Args: + name (str): name displayed on the node. + text (str): backdrop text. + parent (QtWidgets.QGraphicsItem): parent item. + """ + + def __init__(self, name='backdrop', text='', parent=None): + super(BackdropNodeItem, self).__init__(name, parent) + self.setZValue(Z_VAL_BACKDROP) + self._properties['backdrop_text'] = text + self._min_size = 80, 80 + self._sizer = BackdropSizer(self, 26.0) + self._sizer.set_pos(*self._min_size) + self._nodes = [self] + + def _combined_rect(self, nodes): + group = self.scene().createItemGroup(nodes) + rect = group.boundingRect() + self.scene().destroyItemGroup(group) + return rect + + def mouseDoubleClickEvent(self, event): + viewer = self.viewer() + if viewer: + viewer.node_double_clicked.emit(self.id) + super(BackdropNodeItem, self).mouseDoubleClickEvent(event) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + pos = event.scenePos() + rect = QtCore.QRectF(pos.x() - 5, pos.y() - 5, 10, 10) + item = self.scene().items(rect)[0] + + if isinstance(item, (PortItem, PipeItem)): + self.setFlag(self.GraphicsItemFlag.ItemIsMovable, False) + return + if self.selected: + return + + viewer = self.viewer() + [n.setSelected(False) for n in viewer.selected_nodes()] + + self._nodes += self.get_nodes(False) + [n.setSelected(True) for n in self._nodes] + + def mouseReleaseEvent(self, event): + super(BackdropNodeItem, self).mouseReleaseEvent(event) + self.setFlag(self.GraphicsItemFlag.ItemIsMovable, True) + [n.setSelected(True) for n in self._nodes] + self._nodes = [self] + + def on_sizer_pos_changed(self, pos): + self._width = pos.x() + self._sizer.size + self._height = pos.y() + self._sizer.size + + def on_sizer_pos_mouse_release(self): + size = { + 'pos': self.xy_pos, + 'width': self._width, + 'height': self._height} + self.viewer().node_backdrop_updated.emit( + self.id, 'sizer_mouse_release', size) + + def on_sizer_double_clicked(self): + size = self.calc_backdrop_size() + self.viewer().node_backdrop_updated.emit( + self.id, 'sizer_double_clicked', size) + + def paint(self, painter, option, widget): + """ + Draws the backdrop rect. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + painter.save() + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + + margin = 1.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + radius = 2.6 + color = (self.color[0], self.color[1], self.color[2], 50) + painter.setBrush(QtGui.QColor(*color)) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawRoundedRect(rect, radius, radius) + + top_rect = QtCore.QRectF(rect.x(), rect.y(), rect.width(), 26.0) + painter.setBrush(QtGui.QBrush(QtGui.QColor(*self.color))) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawRoundedRect(top_rect, radius, radius) + for pos in [top_rect.left(), top_rect.right() - 5.0]: + painter.drawRect( + QtCore.QRectF(pos, top_rect.bottom() - 5.0, 5.0, 5.0)) + + if self.backdrop_text: + painter.setPen(QtGui.QColor(*self.text_color)) + txt_rect = QtCore.QRectF( + top_rect.x() + 5.0, top_rect.height() + 3.0, + rect.width() - 5.0, rect.height()) + painter.setPen(QtGui.QColor(*self.text_color)) + painter.drawText(txt_rect, + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.TextFlag.TextWordWrap, + self.backdrop_text) + + if self.selected: + sel_color = [x for x in NodeEnum.SELECTED_COLOR.value] + sel_color[-1] = 15 + painter.setBrush(QtGui.QColor(*sel_color)) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawRoundedRect(rect, radius, radius) + + txt_rect = QtCore.QRectF(top_rect.x(), top_rect.y(), + rect.width(), top_rect.height()) + painter.setPen(QtGui.QColor(*self.text_color)) + painter.drawText(txt_rect, QtCore.Qt.AlignmentFlag.AlignCenter, self.name) + + border = 0.8 + border_color = self.color + if self.selected and NodeEnum.SELECTED_BORDER_COLOR.value: + border = 1.0 + border_color = NodeEnum.SELECTED_BORDER_COLOR.value + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtGui.QPen(QtGui.QColor(*border_color), border)) + painter.drawRoundedRect(rect, radius, radius) + + painter.restore() + + def get_nodes(self, inc_intersects=False): + mode = {True: QtCore.Qt.ItemSelectionMode.IntersectsItemShape, + False: QtCore.Qt.ItemSelectionMode.ContainsItemShape} + nodes = [] + if self.scene(): + polygon = self.mapToScene(self.boundingRect()) + rect = polygon.boundingRect() + items = self.scene().items(rect, mode=mode[inc_intersects]) + for item in items: + if item == self or item == self._sizer: + continue + if isinstance(item, AbstractNodeItem): + nodes.append(item) + return nodes + + def calc_backdrop_size(self, nodes=None): + nodes = nodes or self.get_nodes(True) + if nodes: + nodes_rect = self._combined_rect(nodes) + else: + center = self.mapToScene(self.boundingRect().center()) + nodes_rect = QtCore.QRectF( + center.x(), center.y(), + self._min_size[0], self._min_size[1] + ) + + padding = 40 + return { + 'pos': [ + nodes_rect.x() - padding, nodes_rect.y() - padding + ], + 'width': nodes_rect.width() + (padding * 2), + 'height': nodes_rect.height() + (padding * 2) + } + + @property + def minimum_size(self): + return self._min_size + + @minimum_size.setter + def minimum_size(self, size=(50, 50)): + self._min_size = size + + @property + def backdrop_text(self): + return self._properties['backdrop_text'] + + @backdrop_text.setter + def backdrop_text(self, text): + self._properties['backdrop_text'] = text + self.update(self.boundingRect()) + + @AbstractNodeItem.width.setter + def width(self, width=0.0): + AbstractNodeItem.width.fset(self, width) + self._sizer.set_pos(self._width, self._height) + + @AbstractNodeItem.height.setter + def height(self, height=0.0): + AbstractNodeItem.height.fset(self, height) + self._sizer.set_pos(self._width, self._height) + + def from_dict(self, node_dict): + super().from_dict(node_dict) + custom_props = node_dict.get('custom') or {} + for prop_name, value in custom_props.items(): + if prop_name == 'backdrop_text': + self.backdrop_text = value diff --git a/cuegui/NodeGraphQt/qgraphics/node_base.py b/cuegui/NodeGraphQt/qgraphics/node_base.py new file mode 100644 index 000000000..f149dcee0 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_base.py @@ -0,0 +1,1056 @@ +#!/usr/bin/python +from collections import OrderedDict + +from qtpy import QtGui, QtCore, QtWidgets + +from NodeGraphQt.constants import ( + ITEM_CACHE_MODE, + ICON_NODE_BASE, + LayoutDirectionEnum, + NodeEnum, + PortEnum, + PortTypeEnum, + Z_VAL_NODE +) +from NodeGraphQt.errors import NodeWidgetError +from NodeGraphQt.qgraphics.node_abstract import AbstractNodeItem +from NodeGraphQt.qgraphics.node_overlay_disabled import XDisabledItem +from NodeGraphQt.qgraphics.node_text_item import NodeTextItem +from NodeGraphQt.qgraphics.port import PortItem, CustomPortItem + + +class NodeItem(AbstractNodeItem): + """ + Base Node item. + + Args: + name (str): name displayed on the node. + parent (QtWidgets.QGraphicsItem): parent item. + """ + + def __init__(self, name='node', parent=None): + super(NodeItem, self).__init__(name, parent) + pixmap = QtGui.QPixmap(ICON_NODE_BASE) + if pixmap.size().height() > NodeEnum.ICON_SIZE.value: + pixmap = pixmap.scaledToHeight( + NodeEnum.ICON_SIZE.value, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + self._properties['icon'] = ICON_NODE_BASE + self._icon_item = QtWidgets.QGraphicsPixmapItem(pixmap, self) + self._icon_item.setTransformationMode(QtCore.Qt.TransformationMode.SmoothTransformation) + self._text_item = NodeTextItem(self.name, self) + self._x_item = XDisabledItem(self, 'DISABLED') + self._input_items = OrderedDict() + self._output_items = OrderedDict() + self._widgets = OrderedDict() + self._proxy_mode = False + self._proxy_mode_threshold = 70 + + def post_init(self, viewer, pos=None): + """ + Called after node has been added into the scene. + + Args: + viewer (NodeGraphQt.widgets.viewer.NodeViewer): main viewer + pos (tuple): the cursor pos if node is called with tab search. + """ + if self.layout_direction == LayoutDirectionEnum.VERTICAL.value: + font = QtGui.QFont() + font.setPointSize(15) + self.text_item.setFont(font) + + # hide port text items for vertical layout. + if self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + for text_item in self._input_items.values(): + text_item.setVisible(False) + for text_item in self._output_items.values(): + text_item.setVisible(False) + + def _paint_horizontal(self, painter, option, widget): + painter.save() + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + + # base background. + margin = 1.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + radius = 4.0 + painter.setBrush(QtGui.QColor(*self.color)) + painter.drawRoundedRect(rect, radius, radius) + + # light overlay on background when selected. + if self.selected: + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + painter.drawRoundedRect(rect, radius, radius) + + # node name background. + padding = 3.0, 2.0 + text_rect = self._text_item.boundingRect() + text_rect = QtCore.QRectF(text_rect.x() + padding[0], + rect.y() + padding[1], + rect.width() - padding[0] - margin, + text_rect.height() - (padding[1] * 2)) + if self.selected: + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + painter.setBrush(QtGui.QColor(0, 0, 0, 80)) + painter.drawRoundedRect(text_rect, 3.0, 3.0) + + # node border + if self.selected: + border_width = 1.2 + border_color = QtGui.QColor( + *NodeEnum.SELECTED_BORDER_COLOR.value + ) + else: + border_width = 0.8 + border_color = QtGui.QColor(*self.border_color) + + border_rect = QtCore.QRectF(rect.left(), rect.top(), + rect.width(), rect.height()) + + pen = QtGui.QPen(border_color, border_width) + pen.setCosmetic(self.viewer().get_zoom() < 0.0) + path = QtGui.QPainterPath() + path.addRoundedRect(border_rect, radius, radius) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(pen) + painter.drawPath(path) + + painter.restore() + + def _paint_vertical(self, painter, option, widget): + painter.save() + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + + # base background. + margin = 1.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + radius = 4.0 + painter.setBrush(QtGui.QColor(*self.color)) + painter.drawRoundedRect(rect, radius, radius) + + # light overlay on background when selected. + if self.selected: + painter.setBrush( + QtGui.QColor(*NodeEnum.SELECTED_COLOR.value) + ) + painter.drawRoundedRect(rect, radius, radius) + + # top & bottom edge background. + padding = 2.0 + height = 10 + if self.selected: + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + painter.setBrush(QtGui.QColor(0, 0, 0, 80)) + for y in [rect.y() + padding, rect.height() - height - 1]: + edge_rect = QtCore.QRectF(rect.x() + padding, y, + rect.width() - (padding * 2), height) + painter.drawRoundedRect(edge_rect, 3.0, 3.0) + + # node border + border_width = 0.8 + border_color = QtGui.QColor(*self.border_color) + if self.selected: + border_width = 1.2 + border_color = QtGui.QColor( + *NodeEnum.SELECTED_BORDER_COLOR.value + ) + border_rect = QtCore.QRectF(rect.left(), rect.top(), + rect.width(), rect.height()) + + pen = QtGui.QPen(border_color, border_width) + pen.setCosmetic(self.viewer().get_zoom() < 0.0) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(pen) + painter.drawRoundedRect(border_rect, radius, radius) + + painter.restore() + + def paint(self, painter, option, widget): + """ + Draws the node base not the ports. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + self.auto_switch_mode() + if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + self._paint_horizontal(painter, option, widget) + elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + self._paint_vertical(painter, option, widget) + else: + raise RuntimeError('Node graph layout direction not valid!') + + def mousePressEvent(self, event): + """ + Re-implemented to ignore event if LMB is over port collision area. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. + """ + if event.button() == QtCore.Qt.MouseButton.LeftButton: + for p in self._input_items.keys(): + if p.hovered: + event.ignore() + return + for p in self._output_items.keys(): + if p.hovered: + event.ignore() + return + super(NodeItem, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + """ + Re-implemented to ignore event if Alt modifier is pressed. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. + """ + if event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier: + event.ignore() + return + super(NodeItem, self).mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event): + """ + Re-implemented to emit "node_double_clicked" signal. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. + """ + if event.button() == QtCore.Qt.MouseButton.LeftButton: + if not self.disabled: + # enable text item edit mode. + items = self.scene().items(event.scenePos()) + if self._text_item in items: + self._text_item.set_editable(True) + self._text_item.setFocus() + event.ignore() + return + + viewer = self.viewer() + if viewer: + viewer.node_double_clicked.emit(self.id) + super(NodeItem, self).mouseDoubleClickEvent(event) + + def itemChange(self, change, value): + """ + Re-implemented to update pipes on selection changed. + + Args: + change: + value: + """ + if change == self.GraphicsItemChange.ItemSelectedChange and self.scene(): + self.reset_pipes() + if value: + self.highlight_pipes() + self.setZValue(Z_VAL_NODE) + if not self.selected: + self.setZValue(Z_VAL_NODE + 1) + + return super(NodeItem, self).itemChange(change, value) + + def _tooltip_disable(self, state): + """ + Updates the node tooltip when the node is enabled/disabled. + + Args: + state (bool): node disable state. + """ + tooltip = '{}'.format(self.name) + if state: + tooltip += ' (DISABLED)' + tooltip += '
    {}
    '.format(self.type_) + self.setToolTip(tooltip) + + def _set_base_size(self, add_w=0.0, add_h=0.0): + """ + Sets the initial base size for the node. + + Args: + add_w (float): add additional width. + add_h (float): add additional height. + """ + self._width, self._height = self.calc_size(add_w, add_h) + if self._width < NodeEnum.WIDTH.value: + self._width = NodeEnum.WIDTH.value + if self._height < NodeEnum.HEIGHT.value: + self._height = NodeEnum.HEIGHT.value + + def _set_text_color(self, color): + """ + set text color. + + Args: + color (tuple): color value in (r, g, b, a). + """ + text_color = QtGui.QColor(*color) + for port, text in self._input_items.items(): + text.setDefaultTextColor(text_color) + for port, text in self._output_items.items(): + text.setDefaultTextColor(text_color) + self._text_item.setDefaultTextColor(text_color) + + def activate_pipes(self): + """ + active pipe color. + """ + ports = self.inputs + self.outputs + for port in ports: + for pipe in port.connected_pipes: + pipe.activate() + + def highlight_pipes(self): + """ + Highlight pipe color. + """ + ports = self.inputs + self.outputs + for port in ports: + for pipe in port.connected_pipes: + pipe.highlight() + + def reset_pipes(self): + """ + Reset all the pipe colors. + """ + ports = self.inputs + self.outputs + for port in ports: + for pipe in port.connected_pipes: + pipe.reset() + + def _calc_size_horizontal(self): + # width, height from node name text. + text_w = self._text_item.boundingRect().width() + text_h = self._text_item.boundingRect().height() + + # width, height from node ports. + port_width = 0.0 + p_input_text_width = 0.0 + p_output_text_width = 0.0 + p_input_height = 0.0 + p_output_height = 0.0 + for port, text in self._input_items.items(): + if not port.isVisible(): + continue + if not port_width: + port_width = port.boundingRect().width() + t_width = text.boundingRect().width() + if text.isVisible() and t_width > p_input_text_width: + p_input_text_width = text.boundingRect().width() + p_input_height += port.boundingRect().height() + for port, text in self._output_items.items(): + if not port.isVisible(): + continue + if not port_width: + port_width = port.boundingRect().width() + t_width = text.boundingRect().width() + if text.isVisible() and t_width > p_output_text_width: + p_output_text_width = text.boundingRect().width() + p_output_height += port.boundingRect().height() + + port_text_width = p_input_text_width + p_output_text_width + + # width, height from node embedded widgets. + widget_width = 0.0 + widget_height = 0.0 + for widget in self._widgets.values(): + if not widget.isVisible(): + continue + w_width = widget.boundingRect().width() + w_height = widget.boundingRect().height() + if w_width > widget_width: + widget_width = w_width + widget_height += w_height + + side_padding = 0.0 + if all([widget_width, p_input_text_width, p_output_text_width]): + port_text_width = max([p_input_text_width, p_output_text_width]) + port_text_width *= 2 + elif widget_width: + side_padding = 10 + + width = port_width + max([text_w, port_text_width]) + side_padding + height = max([text_h, p_input_height, p_output_height, widget_height]) + if widget_width: + # add additional width for node widget. + width += widget_width + if widget_height: + # add bottom margin for node widget. + height += 4.0 + height *= 1.05 + return width, height + + def _calc_size_vertical(self): + p_input_width = 0.0 + p_output_width = 0.0 + p_input_height = 0.0 + p_output_height = 0.0 + for port in self._input_items.keys(): + if port.isVisible(): + p_input_width += port.boundingRect().width() + if not p_input_height: + p_input_height = port.boundingRect().height() + for port in self._output_items.keys(): + if port.isVisible(): + p_output_width += port.boundingRect().width() + if not p_output_height: + p_output_height = port.boundingRect().height() + + widget_width = 0.0 + widget_height = 0.0 + for widget in self._widgets.values(): + if not widget.isVisible(): + continue + if widget.boundingRect().width() > widget_width: + widget_width = widget.boundingRect().width() + widget_height += widget.boundingRect().height() + + width = max([p_input_width, p_output_width, widget_width]) + height = p_input_height + p_output_height + widget_height + return width, height + + def calc_size(self, add_w=0.0, add_h=0.0): + """ + Calculates the minimum node size. + + Args: + add_w (float): additional width. + add_h (float): additional height. + + Returns: + tuple(float, float): width, height. + """ + if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + width, height = self._calc_size_horizontal() + elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + width, height = self._calc_size_vertical() + else: + raise RuntimeError('Node graph layout direction not valid!') + + # additional width, height. + width += add_w + height += add_h + return width, height + + def _align_icon_horizontal(self, h_offset, v_offset): + icon_rect = self._icon_item.boundingRect() + text_rect = self._text_item.boundingRect() + x = self.boundingRect().left() + 2.0 + y = text_rect.center().y() - (icon_rect.height() / 2) + self._icon_item.setPos(x + h_offset, y + v_offset) + + def _align_icon_vertical(self, h_offset, v_offset): + center_y = self.boundingRect().center().y() + icon_rect = self._icon_item.boundingRect() + text_rect = self._text_item.boundingRect() + x = self.boundingRect().right() + h_offset + y = center_y - text_rect.height() - (icon_rect.height() / 2) + v_offset + self._icon_item.setPos(x, y) + + def align_icon(self, h_offset=0.0, v_offset=0.0): + """ + Align node icon to the default top left of the node. + + Args: + v_offset (float): additional vertical offset. + h_offset (float): additional horizontal offset. + """ + if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + self._align_icon_horizontal(h_offset, v_offset) + elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + self._align_icon_vertical(h_offset, v_offset) + else: + raise RuntimeError('Node graph layout direction not valid!') + + def _align_label_horizontal(self, h_offset, v_offset): + rect = self.boundingRect() + text_rect = self._text_item.boundingRect() + x = rect.center().x() - (text_rect.width() / 2) + self._text_item.setPos(x + h_offset, rect.y() + v_offset) + + def _align_label_vertical(self, h_offset, v_offset): + rect = self._text_item.boundingRect() + x = self.boundingRect().right() + h_offset + y = self.boundingRect().center().y() - (rect.height() / 2) + v_offset + self.text_item.setPos(x, y) + + def align_label(self, h_offset=0.0, v_offset=0.0): + """ + Center node label text to the top of the node. + + Args: + v_offset (float): vertical offset. + h_offset (float): horizontal offset. + """ + if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + self._align_label_horizontal(h_offset, v_offset) + elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + self._align_label_vertical(h_offset, v_offset) + else: + raise RuntimeError('Node graph layout direction not valid!') + + def _align_widgets_horizontal(self, v_offset): + if not self._widgets: + return + rect = self.boundingRect() + y = rect.y() + v_offset + inputs = [p for p in self.inputs if p.isVisible()] + outputs = [p for p in self.outputs if p.isVisible()] + for widget in self._widgets.values(): + if not widget.isVisible(): + continue + widget_rect = widget.boundingRect() + if not inputs: + x = rect.left() + 10 + widget.widget().setTitleAlign('left') + elif not outputs: + x = rect.right() - widget_rect.width() - 10 + widget.widget().setTitleAlign('right') + else: + x = rect.center().x() - (widget_rect.width() / 2) + widget.widget().setTitleAlign('center') + widget.setPos(x, y) + y += widget_rect.height() + + def _align_widgets_vertical(self, v_offset): + if not self._widgets: + return + rect = self.boundingRect() + y = rect.center().y() + v_offset + widget_height = 0.0 + for widget in self._widgets.values(): + if not widget.isVisible(): + continue + widget_rect = widget.boundingRect() + widget_height += widget_rect.height() + y -= widget_height / 2 + + for widget in self._widgets.values(): + if not widget.isVisible(): + continue + widget_rect = widget.boundingRect() + x = rect.center().x() - (widget_rect.width() / 2) + widget.widget().setTitleAlign('center') + widget.setPos(x, y) + y += widget_rect.height() + + def align_widgets(self, v_offset=0.0): + """ + Align node widgets to the default center of the node. + + Args: + v_offset (float): vertical offset. + """ + if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + self._align_widgets_horizontal(v_offset) + elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + self._align_widgets_vertical(v_offset) + else: + raise RuntimeError('Node graph layout direction not valid!') + + def _align_ports_horizontal(self, v_offset): + width = self._width + txt_offset = PortEnum.CLICK_FALLOFF.value - 2 + spacing = 1 + + # adjust input position + inputs = [p for p in self.inputs if p.isVisible()] + if inputs: + port_width = inputs[0].boundingRect().width() + port_height = inputs[0].boundingRect().height() + port_x = (port_width / 2) * -1 + port_y = v_offset + for port in inputs: + port.setPos(port_x, port_y) + port_y += port_height + spacing + # adjust input text position + for port, text in self._input_items.items(): + if port.isVisible(): + txt_x = port.boundingRect().width() / 2 - txt_offset + text.setPos(txt_x, port.y() - 1.5) + + # adjust output position + outputs = [p for p in self.outputs if p.isVisible()] + if outputs: + port_width = outputs[0].boundingRect().width() + port_height = outputs[0].boundingRect().height() + port_x = width - (port_width / 2) + port_y = v_offset + for port in outputs: + port.setPos(port_x, port_y) + port_y += port_height + spacing + # adjust output text position + for port, text in self._output_items.items(): + if port.isVisible(): + txt_width = text.boundingRect().width() - txt_offset + txt_x = port.x() - txt_width + text.setPos(txt_x, port.y() - 1.5) + + def _align_ports_vertical(self, v_offset): + # adjust input position + inputs = [p for p in self.inputs if p.isVisible()] + if inputs: + port_width = inputs[0].boundingRect().width() + port_height = inputs[0].boundingRect().height() + half_width = port_width / 2 + delta = self._width / (len(inputs) + 1) + port_x = delta + port_y = (port_height / 2) * -1 + for port in inputs: + port.setPos(port_x - half_width, port_y) + port_x += delta + + # adjust output position + outputs = [p for p in self.outputs if p.isVisible()] + if outputs: + port_width = outputs[0].boundingRect().width() + port_height = outputs[0].boundingRect().height() + half_width = port_width / 2 + delta = self._width / (len(outputs) + 1) + port_x = delta + port_y = self._height - (port_height / 2) + for port in outputs: + port.setPos(port_x - half_width, port_y) + port_x += delta + + def align_ports(self, v_offset=0.0): + """ + Align input, output ports in the node layout. + + Args: + v_offset (float): port vertical offset. + """ + if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + self._align_ports_horizontal(v_offset) + elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + self._align_ports_vertical(v_offset) + else: + raise RuntimeError('Node graph layout direction not valid!') + + def _draw_node_horizontal(self): + height = self._text_item.boundingRect().height() + 4.0 + + # update port text items in visibility. + for port, text in self._input_items.items(): + if port.isVisible(): + text.setVisible(port.display_name) + for port, text in self._output_items.items(): + if port.isVisible(): + text.setVisible(port.display_name) + + # setup initial base size. + self._set_base_size(add_h=height) + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- set the initial node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label() + # align icon + self.align_icon(h_offset=2.0, v_offset=1.0) + # arrange input and output ports. + self.align_ports(v_offset=height) + # arrange node widgets + self.align_widgets(v_offset=height) + + self.update() + + def _draw_node_vertical(self): + # hide the port text items in vertical layout. + for port, text in self._input_items.items(): + text.setVisible(False) + for port, text in self._output_items.items(): + text.setVisible(False) + + # setup initial base size. + self._set_base_size() + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- setup node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label(h_offset=6) + # align icon + self.align_icon(h_offset=6, v_offset=4) + # arrange input and output ports. + self.align_ports() + # arrange node widgets + self.align_widgets() + + self.update() + + def draw_node(self): + """ + Re-draw the node item in the scene with proper + calculated size and widgets aligned. + """ + if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: + self._draw_node_horizontal() + elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + self._draw_node_vertical() + else: + raise RuntimeError('Node graph layout direction not valid!') + + def post_init(self, viewer=None, pos=None): + """ + Called after node has been added into the scene. + Adjust the node layout and form after the node has been added. + + Args: + viewer (NodeGraphQt.widgets.viewer.NodeViewer): not used + pos (tuple): cursor position. + """ + self.draw_node() + + # set initial node position. + if pos: + self.xy_pos = pos + + def auto_switch_mode(self): + """ + Decide whether to draw the node with proxy mode. + (this is called at the start in the "self.paint()" function.) + """ + if ITEM_CACHE_MODE is QtWidgets.QGraphicsItem.ItemCoordinateCache: + return + + rect = self.sceneBoundingRect() + l = self.viewer().mapToGlobal( + self.viewer().mapFromScene(rect.topLeft())) + r = self.viewer().mapToGlobal( + self.viewer().mapFromScene(rect.topRight())) + # width is the node width in screen + width = r.x() - l.x() + + self.set_proxy_mode(width < self._proxy_mode_threshold) + + def set_proxy_mode(self, mode): + """ + Set whether to draw the node with proxy mode. + (proxy mode toggles visibility for some qgraphic items in the node.) + + Args: + mode (bool): true to enable proxy mode. + """ + if mode is self._proxy_mode: + return + self._proxy_mode = mode + + visible = not mode + + # disable overlay item. + self._x_item.proxy_mode = self._proxy_mode + + # node widget visibility. + for w in self._widgets.values(): + w.widget().setVisible(visible) + + # port text is not visible in vertical layout. + if self.layout_direction is LayoutDirectionEnum.VERTICAL.value: + port_text_visible = False + else: + port_text_visible = visible + + # input port text visibility. + for port, text in self._input_items.items(): + if port.display_name: + text.setVisible(port_text_visible) + + # output port text visibility. + for port, text in self._output_items.items(): + if port.display_name: + text.setVisible(port_text_visible) + + self._text_item.setVisible(visible) + self._icon_item.setVisible(visible) + + @property + def icon(self): + return self._properties['icon'] + + @icon.setter + def icon(self, path=None): + self._properties['icon'] = path + path = path or ICON_NODE_BASE + pixmap = QtGui.QPixmap(path) + if pixmap.size().height() > NodeEnum.ICON_SIZE.value: + pixmap = pixmap.scaledToHeight( + NodeEnum.ICON_SIZE.value, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + self._icon_item.setPixmap(pixmap) + if self.scene(): + self.post_init() + + self.update() + + @AbstractNodeItem.layout_direction.setter + def layout_direction(self, value=0): + AbstractNodeItem.layout_direction.fset(self, value) + self.draw_node() + + @AbstractNodeItem.width.setter + def width(self, width=0.0): + w, h = self.calc_size() + width = width if width > w else w + AbstractNodeItem.width.fset(self, width) + + @AbstractNodeItem.height.setter + def height(self, height=0.0): + w, h = self.calc_size() + h = 70 if h < 70 else h + height = height if height > h else h + AbstractNodeItem.height.fset(self, height) + + @AbstractNodeItem.disabled.setter + def disabled(self, state=False): + AbstractNodeItem.disabled.fset(self, state) + for n, w in self._widgets.items(): + w.widget().setDisabled(state) + self._tooltip_disable(state) + self._x_item.setVisible(state) + + @AbstractNodeItem.selected.setter + def selected(self, selected=False): + AbstractNodeItem.selected.fset(self, selected) + if selected: + self.highlight_pipes() + + @AbstractNodeItem.name.setter + def name(self, name=''): + AbstractNodeItem.name.fset(self, name) + if name == self._text_item.toPlainText(): + return + self._text_item.setPlainText(name) + if self.scene(): + self.align_label() + self.update() + + @AbstractNodeItem.color.setter + def color(self, color=(100, 100, 100, 255)): + AbstractNodeItem.color.fset(self, color) + if self.scene(): + self.scene().update() + self.update() + + @AbstractNodeItem.text_color.setter + def text_color(self, color=(100, 100, 100, 255)): + AbstractNodeItem.text_color.fset(self, color) + self._set_text_color(color) + self.update() + + @property + def text_item(self): + """ + Get the node name text qgraphics item. + + Returns: + NodeTextItem: node text object. + """ + return self._text_item + + @property + def icon_item(self): + """ + Get the node icon qgraphics item. + + Returns: + QtWidgets.QGraphicsPixmapItem: node icon object. + """ + return self._icon_item + + @property + def inputs(self): + """ + Returns: + list[PortItem]: input port graphic items. + """ + return list(self._input_items.keys()) + + @property + def outputs(self): + """ + Returns: + list[PortItem]: output port graphic items. + """ + return list(self._output_items.keys()) + + def _add_port(self, port): + """ + Adds a port qgraphics item into the node. + + Args: + port (PortItem): port item. + + Returns: + PortItem: port qgraphics item. + """ + text = QtWidgets.QGraphicsTextItem(port.name, self) + text.font().setPointSize(8) + text.setFont(text.font()) + text.setVisible(port.display_name) + text.setCacheMode(ITEM_CACHE_MODE) + if port.port_type == PortTypeEnum.IN.value: + self._input_items[port] = text + elif port.port_type == PortTypeEnum.OUT.value: + self._output_items[port] = text + if self.scene(): + self.post_init() + return port + + def add_input(self, name='input', multi_port=False, display_name=True, + locked=False, painter_func=None): + """ + Adds a port qgraphics item into the node with the "port_type" set as + IN_PORT. + + Args: + name (str): name for the port. + multi_port (bool): allow multiple connections. + display_name (bool): display the port name. + locked (bool): locked state. + painter_func (function): custom paint function. + + Returns: + PortItem: input port qgraphics item. + """ + if painter_func: + port = CustomPortItem(self, painter_func) + else: + port = PortItem(self) + port.name = name + port.port_type = PortTypeEnum.IN.value + port.multi_connection = multi_port + port.display_name = display_name + port.locked = locked + return self._add_port(port) + + def add_output(self, name='output', multi_port=False, display_name=True, + locked=False, painter_func=None): + """ + Adds a port qgraphics item into the node with the "port_type" set as + OUT_PORT. + + Args: + name (str): name for the port. + multi_port (bool): allow multiple connections. + display_name (bool): display the port name. + locked (bool): locked state. + painter_func (function): custom paint function. + + Returns: + PortItem: output port qgraphics item. + """ + if painter_func: + port = CustomPortItem(self, painter_func) + else: + port = PortItem(self) + port.name = name + port.port_type = PortTypeEnum.OUT.value + port.multi_connection = multi_port + port.display_name = display_name + port.locked = locked + return self._add_port(port) + + def _delete_port(self, port, text): + """ + Removes port item and port text from node. + + Args: + port (PortItem): port object. + text (QtWidgets.QGraphicsTextItem): port text object. + """ + port.setParentItem(None) + text.setParentItem(None) + self.scene().removeItem(port) + self.scene().removeItem(text) + del port + del text + + def delete_input(self, port): + """ + Remove input port from node. + + Args: + port (PortItem): port object. + """ + self._delete_port(port, self._input_items.pop(port)) + + def delete_output(self, port): + """ + Remove output port from node. + + Args: + port (PortItem): port object. + """ + self._delete_port(port, self._output_items.pop(port)) + + def get_input_text_item(self, port_item): + """ + Args: + port_item (PortItem): port item. + + Returns: + QGraphicsTextItem: graphic item used for the port text. + """ + return self._input_items[port_item] + + def get_output_text_item(self, port_item): + """ + Args: + port_item (PortItem): port item. + + Returns: + QGraphicsTextItem: graphic item used for the port text. + """ + return self._output_items[port_item] + + @property + def widgets(self): + return self._widgets.copy() + + def add_widget(self, widget): + self._widgets[widget.get_name()] = widget + + def get_widget(self, name): + widget = self._widgets.get(name) + if widget: + return widget + raise NodeWidgetError('node has no widget "{}"'.format(name)) + + def has_widget(self, name): + return name in self._widgets.keys() + + def from_dict(self, node_dict): + super(NodeItem, self).from_dict(node_dict) + custom_prop = node_dict.get('custom') or {} + for prop_name, value in custom_prop.items(): + prop_widget = self._widgets.get(prop_name) + if prop_widget: + prop_widget.set_value(value) diff --git a/cuegui/NodeGraphQt/qgraphics/node_circle.py b/cuegui/NodeGraphQt/qgraphics/node_circle.py new file mode 100644 index 000000000..2554b93fb --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_circle.py @@ -0,0 +1,532 @@ +#!/usr/bin/python +from qtpy import QtCore, QtGui, QtWidgets + +from NodeGraphQt.constants import NodeEnum, PortEnum +from NodeGraphQt.qgraphics.node_base import NodeItem + + +class CircleNodeItem(NodeItem): + """ + Circle Node item. + + Args: + name (str): name displayed on the node. + parent (QtWidgets.QGraphicsItem): parent item. + """ + + def __init__(self, name='circle', parent=None): + super(CircleNodeItem, self).__init__(name, parent) + + def _align_ports_horizontal(self, v_offset): + width = self._width + txt_offset = PortEnum.CLICK_FALLOFF.value - 2 + spacing = 1 + + node_center_y = self.boundingRect().center().y() + node_center_y += v_offset + + # adjust input position + inputs = [p for p in self.inputs if p.isVisible()] + if inputs: + port_width = inputs[0].boundingRect().width() + port_height = inputs[0].boundingRect().height() + + count = len(inputs) + if count >= 2: + is_odd = bool(count % 2) + middle_idx = int(count / 2) + + # top half + port_x = (port_width / 2) * -1 + port_y = node_center_y - (port_height / 2) + for idx, port in enumerate(reversed(inputs[:middle_idx])): + if idx == 0: + if is_odd: + port_x += (port_width / 2) - (txt_offset / 2) + port_y -= port_height + spacing + else: + port_y -= (port_height / 2) + spacing + port.setPos(port_x, port_y) + port_x += (port_width / 2) - (txt_offset / 2) + port_y -= port_height + spacing + + # bottom half + port_x = (port_width / 2) * -1 + port_y = node_center_y - (port_height / 2) + for idx, port in enumerate(inputs[middle_idx:]): + if idx == 0: + if not is_odd: + port_y += (port_height / 2) + spacing + port.setPos(port_x, port_y) + port_x += (port_width / 2) - (txt_offset / 2) + port_y += port_height + spacing + else: + port_x = (port_width / 2) * -1 + port_y = node_center_y - (port_height / 2) + inputs[0].setPos(port_x, port_y) + + # adjust input text position + for port, text in self._input_items.items(): + if port.isVisible(): + port_width = port.boundingRect().width() + txt_x = port.pos().x() + port_width - txt_offset + text.setPos(txt_x, port.y() - 1.5) + + # adjust output position + outputs = [p for p in self.outputs if p.isVisible()] + if outputs: + port_width = outputs[0].boundingRect().width() + port_height = outputs[0].boundingRect().height() + + count = len(outputs) + if count >= 2: + is_odd = bool(count % 2) + middle_idx = int(count / 2) + + # top half + port_x = width - (port_width / 2) + port_y = node_center_y - (port_height / 2) + for idx, port in enumerate(reversed(outputs[:middle_idx])): + if idx == 0: + if is_odd: + port_x -= (port_width / 2) - (txt_offset / 2) + port_y -= port_height + spacing + else: + port_y -= (port_height / 2) + spacing + port.setPos(port_x, port_y) + port_x -= (port_width / 2) - (txt_offset / 2) + port_y -= port_height + spacing + + # bottom half + port_x = width - (port_width / 2) + port_y = node_center_y - (port_height / 2) + for idx, port in enumerate(outputs[middle_idx:]): + if idx == 0: + if not is_odd: + port_y += (port_width / 2) - (txt_offset / 2) + port.setPos(port_x, port_y) + port_x -= (port_width / 2) - (txt_offset / 2) + port_y += port_height + spacing + else: + port_x = width - (port_width / 2) + port_y = node_center_y - (port_height / 2) + outputs[0].setPos(port_x, port_y) + + # adjust output text position + for port, text in self._output_items.items(): + if port.isVisible(): + txt_width = text.boundingRect().width() - txt_offset + txt_x = port.x() - txt_width + text.setPos(txt_x, port.y() - 1.5) + + def _align_ports_vertical(self, v_offset): + height = self._height + node_center_x = self.boundingRect().center().x() + v_offset + + # adjust input position + inputs = [p for p in self.inputs if p.isVisible()] + if inputs: + port_width = inputs[0].boundingRect().width() + port_height = inputs[0].boundingRect().height() + + count = len(inputs) + if count > 2: + is_odd = bool(count % 2) + middle_idx = int(count / 2) + + delta = (self._width / (len(inputs) + 1)) / 2 + + # left half + port_x = node_center_x - (port_width / 2) + port_y = (port_height / 2) * -1 + for idx, port in enumerate(reversed(inputs[:middle_idx])): + if idx == 0: + if is_odd: + port_x -= (port_width / 2) + delta + port_y += (port_height / 2) + else: + port_x -= delta + port.setPos(port_x, port_y) + port_x -= (port_width / 2) + delta + port_y += (port_height / 2) + + # right half + port_x = node_center_x - (port_width / 2) + port_y = (port_height / 2) * -1 + for idx, port in enumerate(inputs[middle_idx:]): + if idx == 0: + if not is_odd: + port_x += delta + port.setPos(port_x, port_y) + port_x += (port_width / 2) + delta + port_y += (port_height / 2) + + # adjust output position + outputs = [p for p in self.outputs if p.isVisible()] + if outputs: + port_width = outputs[0].boundingRect().width() + port_height = outputs[0].boundingRect().height() + + count = len(outputs) + if count > 2: + is_odd = bool(count % 2) + middle_idx = int(count / 2) + + delta = (self._width / (len(outputs) + 1)) / 2 + + # left half + port_x = node_center_x - (port_width / 2) + port_y = height - (port_height / 2) + for idx, port in enumerate(reversed(outputs[:middle_idx])): + if idx == 0: + if is_odd: + port_x -= (port_width / 2) + delta + port_y -= (port_height / 2) + else: + port_x -= delta + port.setPos(port_x, port_y) + port_x -= (port_width / 2) + delta + port_y -= (port_height / 2) + + # right half + port_x = node_center_x - (port_width / 2) + port_y = height - (port_height / 2) + for idx, port in enumerate(outputs[middle_idx:]): + if idx == 0: + if not is_odd: + port_x += delta + port.setPos(port_x, port_y) + port_x += (port_width / 2) + delta + port_y -= (port_height / 2) + + def _paint_horizontal(self, painter, option, widget): + painter.save() + + # display node area for debugging + # ---------------------------------------------------------------------- + # pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 80), 0.8) + # pen.setStyle(QtCore.Qt.DotLine) + # painter.setPen(pen) + # painter.drawRect(self.boundingRect()) + # ---------------------------------------------------------------------- + + text_rect = self._text_item.boundingRect() + text_width = text_rect.width() + if text_width < 20.0: + text_width = 20.0 + + text_rect = QtCore.QRectF( + self.boundingRect().center().x() - (text_width / 2), + self.boundingRect().center().y() - (text_rect.height() / 2), + text_rect.width(), + text_rect.height() + ) + + padding = 10.0 + rect = QtCore.QRectF( + text_rect.center().x() - (text_rect.width() / 2) - (padding / 2), + text_rect.center().y() - (text_rect.width() / 2) - (padding / 2), + text_rect.width() + padding, + text_rect.width() + padding + ) + + # draw port lines. + pen_color = QtGui.QColor(*self.border_color) + pen_color.setAlpha(120) + pen = QtGui.QPen(pen_color, 1.5) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + for p in self.inputs: + if p.isVisible(): + p_text = self.get_input_text_item(p) + if p_text.isVisible(): + pt_width = p_text.boundingRect().width() * 1.2 + else: + pt_width = p.boundingRect().width() / 4 + pt1 = QtCore.QPointF( + p.pos().x() + (p.boundingRect().width() / 2) + pt_width, + p.pos().y() + (p.boundingRect().height() / 2) + ) + path = QtGui.QPainterPath() + path.moveTo(pt1) + # path.lineTo(QtCore.QPointF(pt1.x() + 4.0, pt1.y())) + path.lineTo(rect.center()) + painter.drawPath(path) + + for p in self.outputs: + if p.isVisible(): + p_text = self.get_output_text_item(p) + if p_text.isVisible(): + pt_width = p_text.boundingRect().width() * 1.2 + else: + pt_width = p.boundingRect().width() / 4 + pt1 = QtCore.QPointF( + p.pos().x() + (p.boundingRect().width() / 2) - pt_width, + p.pos().y() + (p.boundingRect().height() / 2) + ) + path = QtGui.QPainterPath() + path.moveTo(pt1) + # path.lineTo(QtCore.QPointF(pt1.x() - 2.0, pt1.y())) + path.lineTo(rect.center()) + painter.drawPath(path) + + # draw the base color. + painter.setBrush(QtGui.QColor(*self.color)) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawEllipse(rect) + + # draw outline. + if self.selected: + # light overlay on background when selected. + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + painter.drawEllipse(rect) + + border_width = 1.2 + border_color = QtGui.QColor( + *NodeEnum.SELECTED_BORDER_COLOR.value + ) + else: + border_width = 0.8 + border_color = QtGui.QColor(*self.border_color) + + # draw the outlines. + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtGui.QPen(border_color, border_width)) + painter.drawEllipse(rect) + + # node name background. + text_rect = self._text_item.boundingRect() + text_rect = QtCore.QRectF( + rect.center().x() - (text_rect.width() / 2), + rect.center().y() - (text_rect.height() / 2), + text_rect.width(), + text_rect.height() + ) + if self.selected: + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + painter.setBrush(QtGui.QColor(0, 0, 0, 80)) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawRoundedRect(text_rect, 8.0, 8.0) + + painter.restore() + + def _paint_vertical(self, painter, option, widget): + painter.save() + + # display node area for debugging + # ---------------------------------------------------------------------- + # pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 80), 0.8) + # pen.setStyle(QtCore.Qt.DotLine) + # painter.setPen(pen) + # painter.drawRect(self.boundingRect()) + # ---------------------------------------------------------------------- + + rect = self.boundingRect() + width = min(rect.width(), rect.height()) / 1.8 + rect = QtCore.QRectF( + rect.center().x() - (width / 2), + rect.center().y() - (width / 2), + width, width + ) + + # draw port lines. + pen_color = QtGui.QColor(*self.border_color) + pen_color.setAlpha(120) + pen = QtGui.QPen(pen_color, 1.5) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + for p in self.inputs: + if p.isVisible(): + pt1 = QtCore.QPointF( + p.pos().x() + (p.boundingRect().width() / 2), + p.pos().y() + (p.boundingRect().height() / 2) + ) + path = QtGui.QPainterPath() + path.moveTo(pt1) + path.moveTo(QtCore.QPointF(pt1.x(), pt1.y())) + path.lineTo(rect.center()) + painter.drawPath(path) + + for p in self.outputs: + if p.isVisible(): + pt1 = QtCore.QPointF( + p.pos().x() + (p.boundingRect().width() / 2), + p.pos().y() + (p.boundingRect().height() / 2) + ) + path = QtGui.QPainterPath() + path.moveTo(pt1) + path.lineTo(QtCore.QPointF(pt1.x(), pt1.y())) + path.lineTo(rect.center()) + painter.drawPath(path) + + # draw the base color. + painter.setBrush(QtGui.QColor(*self.color)) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawEllipse(rect) + + # draw outline. + if self.selected: + # light overlay on background when selected. + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + painter.drawEllipse(rect) + + border_width = 1.2 + border_color = QtGui.QColor( + *NodeEnum.SELECTED_BORDER_COLOR.value + ) + else: + border_width = 0.8 + border_color = QtGui.QColor(*self.border_color) + + # draw the outlines. + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtGui.QPen(border_color, border_width)) + painter.drawEllipse(rect) + + painter.restore() + + def _align_icon_horizontal(self, h_offset, v_offset): + icon_rect = self._icon_item.boundingRect() + x = self.boundingRect().center().x() - (icon_rect.width() / 2) + y = self.boundingRect().top() + self._icon_item.setPos(x + h_offset, y + v_offset) + + def _align_icon_vertical(self, h_offset, v_offset): + rect = self.boundingRect() + icon_rect = self._icon_item.boundingRect() + x = rect.left() - icon_rect.width() + (rect.width() / 4) + y = rect.center().y() - (icon_rect.height() / 2) + self._icon_item.setPos(x + h_offset, y + v_offset) + + def _align_widgets_horizontal(self, v_offset): + if not self._widgets: + return + rect = self.boundingRect() + y = rect.bottom() + v_offset + inputs = [p for p in self.inputs if p.isVisible()] + outputs = [p for p in self.outputs if p.isVisible()] + for widget in self._widgets.values(): + widget_rect = widget.boundingRect() + if not inputs: + x = rect.left() + 10 + widget.widget().setTitleAlign('left') + elif not outputs: + x = rect.right() - widget_rect.width() - 10 + widget.widget().setTitleAlign('right') + else: + x = rect.center().x() - (widget_rect.width() / 2) + widget.widget().setTitleAlign('center') + widget.setPos(x, y) + y += widget_rect.height() + + def _align_widgets_vertical(self, v_offset): + if not self._widgets: + return + rect = self.boundingRect() + y = rect.center().y() + v_offset + widget_height = 0.0 + for widget in self._widgets.values(): + widget_rect = widget.boundingRect() + widget_height += widget_rect.height() + y -= widget_height / 2 + + for widget in self._widgets.values(): + widget_rect = widget.boundingRect() + x = rect.center().x() - (widget_rect.width() / 2) + widget.widget().setTitleAlign('center') + widget.setPos(x, y) + y += widget_rect.height() + + def _align_label_horizontal(self, h_offset, v_offset): + rect = self.boundingRect() + text_rect = self._text_item.boundingRect() + x = rect.center().x() - (text_rect.width() / 2) + y = rect.center().y() - (text_rect.height() / 2) + self._text_item.setPos(x + h_offset, y + v_offset) + + def _align_label_vertical(self, h_offset, v_offset): + rect = self.boundingRect() + text_rect = self._text_item.boundingRect() + x = rect.right() - (rect.width() / 4) + y = rect.center().y() - (text_rect.height() / 2) + self._text_item.setPos(x + h_offset, y + v_offset) + + def _draw_node_horizontal(self): + # update port text items in visibility. + text_width = 0 + port_widths = 0 + for port, text in self._input_items.items(): + text.setVisible(port.display_name) + if port.display_name: + if text.boundingRect().width() > text_width: + text_width = text.boundingRect().width() + port_widths += port.boundingRect().width() / len(self._input_items) + + for port, text in self._output_items.items(): + text.setVisible(port.display_name) + if port.display_name: + if text.boundingRect().width() > text_width: + text_width = text.boundingRect().width() + port_widths += port.boundingRect().width() / len(self._output_items) + + add_width = (text_width * 2) + port_widths + add_height = self.text_item.boundingRect().width() / 2 + + # setup initial base size. + self._set_base_size(add_w=add_width, add_h=add_height) + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- set the initial node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label() + # arrange icon + self.align_icon() + # arrange input and output ports. + self.align_ports() + # arrange node widgets + self.align_widgets(v_offset=0.0) + + self.update() + + def _draw_node_vertical(self): + add_height = 0 + + # hide the port text items in vertical layout. + for port, text in self._input_items.items(): + text.setVisible(False) + add_height += port.boundingRect().height() / 2 + for port, text in self._output_items.items(): + text.setVisible(False) + add_height += port.boundingRect().height() / 2 + + if add_height < 50: + add_height = 50 + + # setup initial base size. + self._set_base_size(add_w=50, add_h=add_height) + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- set the initial node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label() + # align icon + self.align_icon() + # arrange input and output ports. + self.align_ports() + # arrange node widgets + self.align_widgets(v_offset=0.0) + + self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_group.py b/cuegui/NodeGraphQt/qgraphics/node_group.py new file mode 100644 index 000000000..19d1b8b1c --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_group.py @@ -0,0 +1,317 @@ +#!/usr/bin/python +from qtpy import QtCore, QtGui, QtWidgets + +from NodeGraphQt.constants import NodeEnum, PortEnum +from NodeGraphQt.qgraphics.node_base import NodeItem + + +class GroupNodeItem(NodeItem): + """ + Group Node item. + + Args: + name (str): name displayed on the node. + parent (QtWidgets.QGraphicsItem): parent item. + """ + + def __init__(self, name='group', parent=None): + super(GroupNodeItem, self).__init__(name, parent) + + def _paint_horizontal(self, painter, option, widget): + painter.save() + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + + # base background. + margin = 6.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + # draw the base color + offset = 3.0 + rect_1 = QtCore.QRectF(rect.x() + (offset / 2), + rect.y() + offset + 2.0, + rect.width(), rect.height()) + rect_2 = QtCore.QRectF(rect.x() - offset, + rect.y() - offset, + rect.width(), rect.height()) + poly = QtGui.QPolygonF() + poly.append(rect_1.topRight()) + poly.append(rect_2.topRight()) + poly.append(rect_2.bottomLeft()) + poly.append(rect_1.bottomLeft()) + + painter.setBrush(QtGui.QColor(*self.color).darker(180)) + painter.drawRect(rect_1) + painter.drawPolygon(poly) + + painter.setBrush(QtGui.QColor(*self.color)) + painter.drawRect(rect_2) + + if self.selected: + border_color = QtGui.QColor( + *NodeEnum.SELECTED_BORDER_COLOR.value + ) + # light overlay on background when selected. + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + painter.drawRect(rect_2) + else: + border_color = QtGui.QColor(*self.border_color) + + # node name background + padding = 2.0, 2.0 + text_rect = self._text_item.boundingRect() + text_rect = QtCore.QRectF(rect_2.left() + padding[0], + rect_2.top() + padding[1], + rect.right() - (padding[0] * 2) - margin, + text_rect.height() - (padding[1] * 2)) + if self.selected: + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + painter.setBrush(QtGui.QColor(0, 0, 0, 80)) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawRect(text_rect) + + # draw the outlines. + pen = QtGui.QPen(border_color.darker(120), 0.8) + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.RoundJoin) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(pen) + painter.drawLines([rect_1.topRight(), rect_2.topRight(), + rect_1.topRight(), rect_1.bottomRight(), + rect_1.bottomRight(), rect_1.bottomLeft(), + rect_1.bottomLeft(), rect_2.bottomLeft()]) + painter.drawLine(rect_1.bottomRight(), rect_2.bottomRight()) + + pen = QtGui.QPen(border_color, 0.8) + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawRect(rect_2) + + painter.restore() + + def _paint_vertical(self, painter, option, widget): + painter.save() + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + + # base background. + margin = 6.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + # draw the base color + offset = 3.0 + rect_1 = QtCore.QRectF(rect.x() + offset, + rect.y() + (offset / 2), + rect.width(), rect.height()) + rect_2 = QtCore.QRectF(rect.x() - offset, + rect.y() - offset, + rect.width(), rect.height()) + poly = QtGui.QPolygonF() + poly.append(rect_1.topRight()) + poly.append(rect_2.topRight()) + poly.append(rect_2.bottomLeft()) + poly.append(rect_1.bottomLeft()) + + painter.setBrush(QtGui.QColor(*self.color).darker(180)) + painter.drawRect(rect_1) + painter.drawPolygon(poly) + painter.setBrush(QtGui.QColor(*self.color)) + painter.drawRect(rect_2) + + if self.selected: + border_color = QtGui.QColor( + *NodeEnum.SELECTED_BORDER_COLOR.value + ) + # light overlay on background when selected. + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + painter.drawRect(rect_2) + else: + border_color = QtGui.QColor(*self.border_color) + + # top & bottom edge background. + padding = 2.0 + height = 10 + if self.selected: + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + painter.setBrush(QtGui.QColor(0, 0, 0, 80)) + + painter.setPen(QtCore.Qt.PenStyle.NoPen) + for y in [rect_2.top() + padding, rect_2.bottom() - height - padding]: + top_rect = QtCore.QRectF(rect.x() + padding - offset, y, + rect.width() - (padding * 2), height) + painter.drawRect(top_rect) + + # draw the outlines. + pen = QtGui.QPen(border_color.darker(120), 0.8) + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(pen) + painter.drawLines([rect_1.topRight(), rect_2.topRight(), + rect_1.topRight(), rect_1.bottomRight(), + rect_1.bottomRight(), rect_1.bottomLeft(), + rect_1.bottomLeft(), rect_2.bottomLeft()]) + painter.drawLine(rect_1.bottomRight(), rect_2.bottomRight()) + + pen = QtGui.QPen(border_color, 0.8) + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawRect(rect_2) + + painter.restore() + + def _align_icon_horizontal(self, h_offset, v_offset): + super(GroupNodeItem, self)._align_icon_horizontal(h_offset, v_offset) + + def _align_icon_vertical(self, h_offset, v_offset): + y = self._height / 2 + y -= self._icon_item.boundingRect().height() + self._icon_item.setPos(self._width + h_offset, y + v_offset) + + def _align_label_horizontal(self, h_offset, v_offset): + super(GroupNodeItem, self)._align_label_horizontal(h_offset, v_offset) + + def _align_label_vertical(self, h_offset, v_offset): + y = self._height / 2 + y -= self.text_item.boundingRect().height() / 2 + self._text_item.setPos(self._width + h_offset, y + v_offset) + + def _align_ports_horizontal(self, v_offset): + width = self._width + txt_offset = PortEnum.CLICK_FALLOFF.value - 2 + spacing = 1 + + # adjust input position + inputs = [p for p in self.inputs if p.isVisible()] + if inputs: + port_width = inputs[0].boundingRect().width() + port_height = inputs[0].boundingRect().height() + port_x = port_width / 2 * -1 + port_x += 3.0 + port_y = v_offset + for port in inputs: + port.setPos(port_x, port_y) + port_y += port_height + spacing + # adjust input text position + for port, text in self._input_items.items(): + if port.isVisible(): + txt_x = port.boundingRect().width() / 2 - txt_offset + txt_x += 3.0 + text.setPos(txt_x, port.y() - 1.5) + + # adjust output position + outputs = [p for p in self.outputs if p.isVisible()] + if outputs: + port_width = outputs[0].boundingRect().width() + port_height = outputs[0].boundingRect().height() + port_x = width - (port_width / 2) + port_x -= 9.0 + port_y = v_offset + for port in outputs: + port.setPos(port_x, port_y) + port_y += port_height + spacing + # adjust output text position + for port, text in self._output_items.items(): + if port.isVisible(): + txt_width = text.boundingRect().width() - txt_offset + txt_x = port.x() - txt_width + text.setPos(txt_x, port.y() - 1.5) + + def _align_ports_vertical(self, v_offset): + # adjust input position + inputs = [p for p in self.inputs if p.isVisible()] + if inputs: + port_width = inputs[0].boundingRect().width() + port_height = inputs[0].boundingRect().height() + half_width = port_width / 2 + delta = self._width / (len(inputs) + 1) + port_x = delta + port_y = -port_height / 2 + 3.0 + for port in inputs: + port.setPos(port_x - half_width, port_y) + port_x += delta + + # adjust output position + outputs = [p for p in self.outputs if p.isVisible()] + if outputs: + port_width = outputs[0].boundingRect().width() + port_height = outputs[0].boundingRect().height() + half_width = port_width / 2 + delta = self._width / (len(outputs) + 1) + port_x = delta + port_y = self._height - (port_height / 2) - 9.0 + for port in outputs: + port.setPos(port_x - half_width, port_y) + port_x += delta + + def _draw_node_horizontal(self): + height = self._text_item.boundingRect().height() + + # update port text items in visibility. + for port, text in self._input_items.items(): + text.setVisible(port.display_name) + for port, text in self._output_items.items(): + text.setVisible(port.display_name) + + # setup initial base size. + self._set_base_size(add_w=8.0, add_h=height + 10) + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- set the initial node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label() + # arrange icon + self.align_icon(h_offset=2.0, v_offset=3.0) + # arrange input and output ports. + self.align_ports(v_offset=height) + # arrange node widgets + self.align_widgets(v_offset=height) + + self.update() + + def _draw_node_vertical(self): + height = self._text_item.boundingRect().height() + + # hide the port text items in vertical layout. + for port, text in self._input_items.items(): + text.setVisible(False) + for port, text in self._output_items.items(): + text.setVisible(False) + + # setup initial base size. + self._set_base_size(add_w=8.0) + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- set the initial node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label(h_offset=7, v_offset=6) + # align icon + self.align_icon(h_offset=4, v_offset=-2) + # arrange input and output ports. + self.align_ports(v_offset=height + (height / 2)) + # arrange node widgets + self.align_widgets(v_offset=height / 2) + + self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py b/cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py new file mode 100644 index 000000000..9d8401145 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py @@ -0,0 +1,108 @@ +#!/usr/bin/python +from qtpy import QtGui, QtCore, QtWidgets + +from NodeGraphQt.constants import Z_VAL_NODE_WIDGET + + +class XDisabledItem(QtWidgets.QGraphicsItem): + """ + Node disabled overlay item. + + Args: + parent (NodeItem): the parent node item. + text (str): disable overlay text. + """ + + def __init__(self, parent=None, text=None): + super(XDisabledItem, self).__init__(parent) + self.setZValue(Z_VAL_NODE_WIDGET + 2) + self.setVisible(False) + self.proxy_mode = False + self.color = (0, 0, 0, 255) + self.text = text + + def boundingRect(self): + return self.parentItem().boundingRect() + + def paint(self, painter, option, widget): + """ + Draws the overlay disabled X item on top of a node item. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + painter.save() + + margin = 20 + rect = self.boundingRect() + dis_rect = QtCore.QRectF(rect.left() - (margin / 2), + rect.top() - (margin / 2), + rect.width() + margin, + rect.height() + margin) + if not self.proxy_mode: + pen = QtGui.QPen(QtGui.QColor(*self.color), 8) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawLine(dis_rect.topLeft(), dis_rect.bottomRight()) + painter.drawLine(dis_rect.topRight(), dis_rect.bottomLeft()) + + bg_color = QtGui.QColor(*self.color) + bg_color.setAlpha(100) + bg_margin = -0.5 + bg_rect = QtCore.QRectF(dis_rect.left() - (bg_margin / 2), + dis_rect.top() - (bg_margin / 2), + dis_rect.width() + bg_margin, + dis_rect.height() + bg_margin) + painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0, 0))) + painter.setBrush(bg_color) + painter.drawRoundedRect(bg_rect, 5, 5) + + if not self.proxy_mode: + point_size = 4.0 + pen = QtGui.QPen(QtGui.QColor(155, 0, 0, 255), 0.7) + else: + point_size = 8.0 + pen = QtGui.QPen(QtGui.QColor(155, 0, 0, 255), 4.0) + + painter.setPen(pen) + painter.drawLine(dis_rect.topLeft(), dis_rect.bottomRight()) + painter.drawLine(dis_rect.topRight(), dis_rect.bottomLeft()) + + point_pos = (dis_rect.topLeft(), dis_rect.topRight(), + dis_rect.bottomLeft(), dis_rect.bottomRight()) + painter.setBrush(QtGui.QColor(255, 0, 0, 255)) + for p in point_pos: + p.setX(p.x() - (point_size / 2)) + p.setY(p.y() - (point_size / 2)) + point_rect = QtCore.QRectF( + p, QtCore.QSizeF(point_size, point_size)) + painter.drawEllipse(point_rect) + + if self.text and not self.proxy_mode: + font = painter.font() + font.setPointSize(10) + + painter.setFont(font) + font_metrics = QtGui.QFontMetrics(font) + font_width = font_metrics.width(self.text) + font_height = font_metrics.height() + txt_w = font_width * 1.25 + txt_h = font_height * 2.25 + text_bg_rect = QtCore.QRectF((rect.width() / 2) - (txt_w / 2), + (rect.height() / 2) - (txt_h / 2), + txt_w, txt_h) + painter.setPen(QtGui.QPen(QtGui.QColor(255, 0, 0), 0.5)) + painter.setBrush(QtGui.QColor(*self.color)) + painter.drawRoundedRect(text_bg_rect, 2, 2) + + text_rect = QtCore.QRectF((rect.width() / 2) - (font_width / 2), + (rect.height() / 2) - (font_height / 2), + txt_w * 2, font_height * 2) + + painter.setPen(QtGui.QPen(QtGui.QColor(255, 0, 0), 1)) + painter.drawText(text_rect, self.text) + + painter.restore() diff --git a/cuegui/NodeGraphQt/qgraphics/node_port_in.py b/cuegui/NodeGraphQt/qgraphics/node_port_in.py new file mode 100644 index 000000000..aa5981cb3 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_port_in.py @@ -0,0 +1,234 @@ +#!/usr/bin/python +from qtpy import QtCore, QtGui, QtWidgets + +from NodeGraphQt.constants import NodeEnum +from NodeGraphQt.qgraphics.node_base import NodeItem + + +class PortInputNodeItem(NodeItem): + """ + Input Port Node item. + + Args: + name (str): name displayed on the node. + parent (QtWidgets.QGraphicsItem): parent item. + """ + + def __init__(self, name='group port', parent=None): + super(PortInputNodeItem, self).__init__(name, parent) + self._icon_item.setVisible(False) + self._text_item.set_locked(True) + self._x_item.text = 'Port Locked' + + def _set_base_size(self, add_w=0.0, add_h=0.0): + width, height = self.calc_size(add_w, add_h) + self._width = width + 60 + self._height = height if height >= 60 else 60 + + def _paint_horizontal(self, painter, option, widget): + self.auto_switch_mode() + + painter.save() + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + + margin = 2.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + text_rect = self._text_item.boundingRect() + text_rect = QtCore.QRectF( + rect.center().x() - (text_rect.width() / 2) - 5, + rect.center().y() - (text_rect.height() / 2), + text_rect.width() + 10, + text_rect.height() + ) + + painter.setBrush(QtGui.QColor(255, 255, 255, 20)) + painter.drawRoundedRect(rect, 20, 20) + + painter.setBrush(QtGui.QColor(0, 0, 0, 100)) + painter.drawRoundedRect(text_rect, 3, 3) + + size = int(rect.height() / 4) + triangle = QtGui.QPolygonF() + triangle.append(QtCore.QPointF(-size, size)) + triangle.append(QtCore.QPointF(0.0, 0.0)) + triangle.append(QtCore.QPointF(size, size)) + + transform = QtGui.QTransform() + transform.translate(rect.width() - (size / 6), rect.center().y()) + transform.rotate(90) + poly = transform.map(triangle) + + if self.selected: + pen = QtGui.QPen( + QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 + ) + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) + painter.setBrush(QtGui.QColor(0, 0, 0, 50)) + + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + painter.setPen(pen) + painter.drawPolygon(poly) + + edge_size = 30 + edge_rect = QtCore.QRectF(rect.width() - (size * 1.7), + rect.center().y() - (edge_size / 2), + 4, edge_size) + painter.drawRect(edge_rect) + + painter.restore() + + def _paint_vertical(self, painter, option, widget): + self.auto_switch_mode() + + painter.save() + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + + margin = 2.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + text_rect = self._text_item.boundingRect() + text_rect = QtCore.QRectF( + rect.center().x() - (text_rect.width() / 2) - 5, + rect.top() + margin, + text_rect.width() + 10, + text_rect.height() + ) + + painter.setBrush(QtGui.QColor(255, 255, 255, 20)) + painter.drawRoundedRect(rect, 20, 20) + + painter.setBrush(QtGui.QColor(0, 0, 0, 100)) + painter.drawRoundedRect(text_rect, 3, 3) + + size = int(rect.height() / 4) + triangle = QtGui.QPolygonF() + triangle.append(QtCore.QPointF(-size, size)) + triangle.append(QtCore.QPointF(0.0, 0.0)) + triangle.append(QtCore.QPointF(size, size)) + + transform = QtGui.QTransform() + transform.translate(rect.center().x(), rect.bottom() - (size / 3)) + transform.rotate(180) + poly = transform.map(triangle) + + if self.selected: + pen = QtGui.QPen( + QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 + ) + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) + painter.setBrush(QtGui.QColor(0, 0, 0, 50)) + + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + painter.setPen(pen) + painter.drawPolygon(poly) + + edge_size = 30 + edge_rect = QtCore.QRectF(rect.center().x() - (edge_size / 2), + rect.bottom() - (size * 1.9), + edge_size, 4) + painter.drawRect(edge_rect) + + painter.restore() + + def set_proxy_mode(self, mode): + """ + Set whether to draw the node with proxy mode. + (proxy mode toggles visibility for some qgraphic items in the node.) + + Args: + mode (bool): true to enable proxy mode. + """ + if mode is self._proxy_mode: + return + self._proxy_mode = mode + + visible = not mode + + # disable overlay item. + self._x_item.proxy_mode = self._proxy_mode + + # node widget visibility. + for w in self._widgets.values(): + w.widget().setVisible(visible) + + # input port text visibility. + for port, text in self._input_items.items(): + if port.display_name: + text.setVisible(visible) + + # output port text visibility. + for port, text in self._output_items.items(): + if port.display_name: + text.setVisible(visible) + + self._text_item.setVisible(visible) + + def _align_label_horizontal(self, h_offset, v_offset): + rect = self.boundingRect() + text_rect = self._text_item.boundingRect() + x = rect.center().x() - (text_rect.width() / 2) + y = rect.center().y() - (text_rect.height() / 2) + self._text_item.setPos(x + h_offset, y + v_offset) + + def _align_label_vertical(self, h_offset, v_offset): + rect = self.boundingRect() + text_rect = self._text_item.boundingRect() + x = rect.center().x() - (text_rect.width() / 1.5) - 2.0 + y = rect.center().y() - text_rect.height() - 2.0 + self._text_item.setPos(x + h_offset, y + v_offset) + + def _align_ports_horizontal(self, v_offset): + """ + Align input, output ports in the node layout. + """ + v_offset = self.boundingRect().height() / 2 + if self.inputs or self.outputs: + for ports in [self.inputs, self.outputs]: + if ports: + v_offset -= ports[0].boundingRect().height() / 2 + break + super(PortInputNodeItem, self)._align_ports_horizontal(v_offset) + + def _align_ports_vertical(self, v_offset): + super(PortInputNodeItem, self)._align_ports_vertical(v_offset) + + def _draw_node_horizontal(self): + """ + Re-draw the node item in the scene. + (re-implemented for vertical layout design) + """ + # setup initial base size. + self._set_base_size() + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- set the initial node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label() + # arrange icon + self.align_icon() + # arrange input and output ports. + self.align_ports() + # arrange node widgets + self.align_widgets() + + self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_port_out.py b/cuegui/NodeGraphQt/qgraphics/node_port_out.py new file mode 100644 index 000000000..cce6d89d8 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_port_out.py @@ -0,0 +1,234 @@ +#!/usr/bin/python +from qtpy import QtCore, QtGui, QtWidgets + +from NodeGraphQt.constants import NodeEnum +from NodeGraphQt.qgraphics.node_base import NodeItem + + +class PortOutputNodeItem(NodeItem): + """ + Output Port Node item. + + Args: + name (str): name displayed on the node. + parent (QtWidgets.QGraphicsItem): parent item. + """ + + def __init__(self, name='group port', parent=None): + super(PortOutputNodeItem, self).__init__(name, parent) + self._icon_item.setVisible(False) + self._text_item.set_locked(True) + self._x_item.text = 'Port Locked' + + def _set_base_size(self, add_w=0.0, add_h=0.0): + width, height = self.calc_size(add_w, add_h) + self._width = width + 60 + self._height = height if height >= 60 else 60 + + def _paint_horizontal(self, painter, option, widget): + self.auto_switch_mode() + + painter.save() + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + + margin = 2.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + text_rect = self._text_item.boundingRect() + text_rect = QtCore.QRectF( + rect.center().x() - (text_rect.width() / 2) - 5, + rect.center().y() - (text_rect.height() / 2), + text_rect.width() + 10, + text_rect.height() + ) + + painter.setBrush(QtGui.QColor(255, 255, 255, 20)) + painter.drawRoundedRect(rect, 20, 20) + + painter.setBrush(QtGui.QColor(0, 0, 0, 100)) + painter.drawRoundedRect(text_rect, 3, 3) + + size = int(rect.height() / 4) + triangle = QtGui.QPolygonF() + triangle.append(QtCore.QPointF(-size, size)) + triangle.append(QtCore.QPointF(0.0, 0.0)) + triangle.append(QtCore.QPointF(size, size)) + + transform = QtGui.QTransform() + transform.translate(rect.x() + (size / 3), rect.center().y()) + transform.rotate(-90) + poly = transform.map(triangle) + + if self.selected: + pen = QtGui.QPen( + QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 + ) + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) + painter.setBrush(QtGui.QColor(0, 0, 0, 50)) + + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + painter.setPen(pen) + painter.drawPolygon(poly) + + edge_size = 30 + edge_rect = QtCore.QRectF(rect.x() + (size * 1.6), + rect.center().y() - (edge_size / 2), + 4, edge_size) + painter.drawRect(edge_rect) + + painter.restore() + + def _paint_vertical(self, painter, option, widget): + self.auto_switch_mode() + + painter.save() + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + + margin = 2.0 + rect = self.boundingRect() + rect = QtCore.QRectF(rect.left() + margin, + rect.top() + margin, + rect.width() - (margin * 2), + rect.height() - (margin * 2)) + + text_rect = self._text_item.boundingRect() + text_rect = QtCore.QRectF( + rect.center().x() - (text_rect.width() / 2) - 5, + rect.height() - text_rect.height(), + text_rect.width() + 10, + text_rect.height() + ) + + painter.setBrush(QtGui.QColor(255, 255, 255, 20)) + painter.drawRoundedRect(rect, 20, 20) + + painter.setBrush(QtGui.QColor(0, 0, 0, 100)) + painter.drawRoundedRect(text_rect, 3, 3) + + size = int(rect.height() / 4) + triangle = QtGui.QPolygonF() + triangle.append(QtCore.QPointF(-size, size)) + triangle.append(QtCore.QPointF(0.0, 0.0)) + triangle.append(QtCore.QPointF(size, size)) + + transform = QtGui.QTransform() + transform.translate(rect.center().x(), rect.y() + (size / 3)) + # transform.rotate(-90) + poly = transform.map(triangle) + + if self.selected: + pen = QtGui.QPen( + QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 + ) + painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) + else: + pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) + painter.setBrush(QtGui.QColor(0, 0, 0, 50)) + + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + painter.setPen(pen) + painter.drawPolygon(poly) + + edge_size = 30 + edge_rect = QtCore.QRectF(rect.center().x() - (edge_size / 2), + rect.y() + (size * 1.6), + edge_size, 4) + painter.drawRect(edge_rect) + + painter.restore() + + def set_proxy_mode(self, mode): + """ + Set whether to draw the node with proxy mode. + (proxy mode toggles visibility for some qgraphic items in the node.) + + Args: + mode (bool): true to enable proxy mode. + """ + if mode is self._proxy_mode: + return + self._proxy_mode = mode + + visible = not mode + + # disable overlay item. + self._x_item.proxy_mode = self._proxy_mode + + # node widget visibility. + for w in self._widgets.values(): + w.widget().setVisible(visible) + + # input port text visibility. + for port, text in self._input_items.items(): + if port.display_name: + text.setVisible(visible) + + # output port text visibility. + for port, text in self._output_items.items(): + if port.display_name: + text.setVisible(visible) + + self._text_item.setVisible(visible) + + def _align_label_horizontal(self, h_offset, v_offset): + rect = self.boundingRect() + text_rect = self._text_item.boundingRect() + x = rect.center().x() - (text_rect.width() / 2) + y = rect.center().y() - (text_rect.height() / 2) + self._text_item.setPos(x + h_offset, y + v_offset) + + def _align_label_vertical(self, h_offset, v_offset): + rect = self.boundingRect() + text_rect = self._text_item.boundingRect() + x = rect.center().x() - (text_rect.width() / 1.5) - 2.0 + y = rect.height() - text_rect.height() - 4.0 + self._text_item.setPos(x + h_offset, y + v_offset) + + def _align_ports_horizontal(self, v_offset): + """ + Align input, output ports in the node layout. + """ + v_offset = self.boundingRect().height() / 2 + if self.inputs or self.outputs: + for ports in [self.inputs, self.outputs]: + if ports: + v_offset -= ports[0].boundingRect().height() / 2 + break + super(PortOutputNodeItem, self)._align_ports_horizontal(v_offset) + + def _align_ports_vertical(self, v_offset): + super(PortOutputNodeItem, self)._align_ports_vertical(v_offset) + + def _draw_node_horizontal(self): + """ + Re-draw the node item in the scene. + (re-implemented for vertical layout design) + """ + # setup initial base size. + self._set_base_size() + # set text color when node is initialized. + self._set_text_color(self.text_color) + # set the tooltip + self._tooltip_disable(self.disabled) + + # --- set the initial node layout --- + # (do all the graphic item layout offsets here) + + # align label text + self.align_label() + # align icon + self.align_icon() + # arrange input and output ports. + self.align_ports() + # arrange node widgets + self.align_widgets() + + self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_text_item.py b/cuegui/NodeGraphQt/qgraphics/node_text_item.py new file mode 100644 index 000000000..e8840dc1f --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/node_text_item.py @@ -0,0 +1,117 @@ +from qtpy import QtWidgets, QtCore, QtGui + + +class NodeTextItem(QtWidgets.QGraphicsTextItem): + """ + NodeTextItem class used to display and edit the name of a NodeItem. + """ + + def __init__(self, text, parent=None): + super(NodeTextItem, self).__init__(text, parent) + self._locked = False + self.set_locked(False) + self.set_editable(False) + + def mouseDoubleClickEvent(self, event): + """ + Re-implemented to jump into edit mode when user clicks on node text. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. + """ + if not self._locked: + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.set_editable(True) + event.ignore() + return + super(NodeTextItem, self).mouseDoubleClickEvent(event) + + def keyPressEvent(self, event): + """ + Re-implemented to catch the Return & Escape keys when in edit mode. + + Args: + event (QtGui.QKeyEvent): key event. + """ + if event.key() == QtCore.Qt.Key.Key_Return: + current_text = self.toPlainText() + self.set_node_name(current_text) + self.set_editable(False) + elif event.key() == QtCore.Qt.Key.Key_Escape: + self.setPlainText(self.node.name) + self.set_editable(False) + super(NodeTextItem, self).keyPressEvent(event) + + def focusOutEvent(self, event): + """ + Re-implemented to jump out of edit mode. + + Args: + event (QtGui.QFocusEvent): + """ + current_text = self.toPlainText() + self.set_node_name(current_text) + self.set_editable(False) + super(NodeTextItem, self).focusOutEvent(event) + + def set_editable(self, value=False): + """ + Set the edit mode for the text item. + + Args: + value (bool): true in edit mode. + """ + if self._locked: + return + if value: + self.setTextInteractionFlags( + QtCore.Qt.TextInteractionFlag.TextEditable | + QtCore.Qt.TextInteractionFlag.TextSelectableByMouse | + QtCore.Qt.TextInteractionFlag.TextSelectableByKeyboard + ) + else: + self.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.NoTextInteraction) + cursor = self.textCursor() + cursor.clearSelection() + self.setTextCursor(cursor) + + def set_node_name(self, name): + """ + Updates the node name through the node "NodeViewer().node_name_changed" + signal which then updates the node name through the BaseNode object this + will register it as an undo command. + + Args: + name (str): new node name. + """ + name = name.strip() + if name != self.node.name: + viewer = self.node.viewer() + viewer.node_name_changed.emit(self.node.id, name) + + def set_locked(self, state=False): + """ + Locks the text item so it can not be editable. + + Args: + state (bool): lock state. + """ + self._locked = state + if self._locked: + self.setFlag(QtWidgets.QGraphicsItem.ItemIsFocusable, False) + self.setCursor(QtCore.Qt.CursorShape.ArrowCursor) + self.setToolTip('') + else: + self.setFlag(QtWidgets.QGraphicsItem.ItemIsFocusable, True) + self.setToolTip('double-click to edit node name.') + self.setCursor(QtCore.Qt.CursorShape.IBeamCursor) + + @property + def node(self): + """ + Get the parent node item. + + Returns: + NodeItem: parent node qgraphics item. + """ + return self.parentItem() diff --git a/cuegui/NodeGraphQt/qgraphics/pipe.py b/cuegui/NodeGraphQt/qgraphics/pipe.py new file mode 100644 index 000000000..85d8aa1e1 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/pipe.py @@ -0,0 +1,666 @@ +#!/usr/bin/python +import math + +from qtpy import QtCore, QtGui, QtWidgets + +from NodeGraphQt.constants import ( + LayoutDirectionEnum, + PipeEnum, + PipeLayoutEnum, + PortTypeEnum, + ITEM_CACHE_MODE, + Z_VAL_PIPE, + Z_VAL_NODE_WIDGET +) +from NodeGraphQt.qgraphics.port import PortItem + +PIPE_STYLES = { + PipeEnum.DRAW_TYPE_DEFAULT.value: QtCore.Qt.PenStyle.SolidLine, + PipeEnum.DRAW_TYPE_DASHED.value: QtCore.Qt.PenStyle.DashLine, + PipeEnum.DRAW_TYPE_DOTTED.value: QtCore.Qt.PenStyle.DotLine +} + + +class PipeItem(QtWidgets.QGraphicsPathItem): + """ + Base Pipe item used for drawing node connections. + """ + + def __init__(self, input_port=None, output_port=None): + super(PipeItem, self).__init__() + self.setZValue(Z_VAL_PIPE) + self.setAcceptHoverEvents(True) + self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable) + self.setCacheMode(ITEM_CACHE_MODE) + + self._color = PipeEnum.COLOR.value + self._style = PipeEnum.DRAW_TYPE_DEFAULT.value + self._active = False + self._highlight = False + self._input_port = input_port + self._output_port = output_port + + size = 6.0 + self._poly = QtGui.QPolygonF() + self._poly.append(QtCore.QPointF(-size, size)) + self._poly.append(QtCore.QPointF(0.0, -size * 1.5)) + self._poly.append(QtCore.QPointF(size, size)) + + self._dir_pointer = QtWidgets.QGraphicsPolygonItem(self) + self._dir_pointer.setPolygon(self._poly) + self._dir_pointer.setFlag(self.GraphicsItemFlag.ItemIsSelectable, False) + + self.reset() + + def __repr__(self): + in_name = self._input_port.name if self._input_port else '' + out_name = self._output_port.name if self._output_port else '' + return '{}.Pipe(\'{}\', \'{}\')'.format( + self.__module__, in_name, out_name) + + def hoverEnterEvent(self, event): + self.activate() + + def hoverLeaveEvent(self, event): + self.reset() + if self.input_port and self.output_port: + if self.input_port.node.selected: + self.highlight() + elif self.output_port.node.selected: + self.highlight() + if self.isSelected(): + self.highlight() + + def itemChange(self, change, value): + if change == self.GraphicsItemChange.ItemSelectedChange and self.scene(): + if value: + self.highlight() + else: + self.reset() + return super(PipeItem, self).itemChange(change, value) + + def paint(self, painter, option, widget): + """ + Draws the connection line between nodes. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + painter.save() + + pen = self.pen() + if self.disabled(): + if not self._active: + pen.setColor(QtGui.QColor(*PipeEnum.DISABLED_COLOR.value)) + pen.setStyle(PIPE_STYLES.get(PipeEnum.DRAW_TYPE_DOTTED.value)) + pen.setWidth(3) + + painter.setPen(pen) + painter.setBrush(self.brush()) + painter.setRenderHint(painter.RenderHint.Antialiasing, True) + painter.drawPath(self.path()) + + # QPaintDevice: Cannot destroy paint device that is being painted. + painter.restore() + + @staticmethod + def _calc_distance(p1, p2): + x = math.pow((p2.x() - p1.x()), 2) + y = math.pow((p2.y() - p1.y()), 2) + return math.sqrt(x + y) + + def _draw_direction_pointer(self): + """ + updates the pipe direction pointer arrow. + """ + if not (self.input_port and self.output_port): + self._dir_pointer.setVisible(False) + return + + if self.disabled(): + if not (self._active or self._highlight): + color = QtGui.QColor(*PipeEnum.DISABLED_COLOR.value) + pen = self._dir_pointer.pen() + pen.setColor(color) + self._dir_pointer.setPen(pen) + self._dir_pointer.setBrush(color.darker(200)) + + self._dir_pointer.setVisible(True) + loc_pt = self.path().pointAtPercent(0.49) + tgt_pt = self.path().pointAtPercent(0.51) + radians = math.atan2(tgt_pt.y() - loc_pt.y(), + tgt_pt.x() - loc_pt.x()) + degrees = math.degrees(radians) - 90 + self._dir_pointer.setRotation(degrees) + self._dir_pointer.setPos(self.path().pointAtPercent(0.5)) + + cen_x = self.path().pointAtPercent(0.5).x() + cen_y = self.path().pointAtPercent(0.5).y() + dist = math.hypot(tgt_pt.x() - cen_x, tgt_pt.y() - cen_y) + + self._dir_pointer.setVisible(True) + if dist < 0.3: + self._dir_pointer.setVisible(False) + return + if dist < 1.0: + self._dir_pointer.setScale(dist) + + def _draw_path_cycled_vertical(self, start_port, pos1, pos2, path): + """ + Draw pipe vertically around node if connection is cyclic. + + Args: + start_port (PortItem): port used to draw the starting point. + pos1 (QPointF): start port position. + pos2 (QPointF): end port position. + path (QPainterPath): path to draw. + """ + n_rect = start_port.node.boundingRect() + ptype = start_port.port_type + start_pos = pos1 if ptype == PortTypeEnum.IN.value else pos2 + end_pos = pos2 if ptype == PortTypeEnum.IN.value else pos1 + + padding = 40 + top = start_pos.y() - padding + bottom = end_pos.y() + padding + path.moveTo(end_pos) + path.lineTo(end_pos.x(), bottom) + path.lineTo(end_pos.x() + n_rect.right(), bottom) + path.lineTo(end_pos.x() + n_rect.right(), top) + path.lineTo(start_pos.x(), top) + path.lineTo(start_pos) + self.setPath(path) + + def _draw_path_cycled_horizontal(self, start_port, pos1, pos2, path): + """ + Draw pipe horizontally around node if connection is cyclic. + + Args: + start_port (PortItem): port used to draw the starting point. + pos1 (QPointF): start port position. + pos2 (QPointF): end port position. + path (QPainterPath): path to draw. + """ + n_rect = start_port.node.boundingRect() + ptype = start_port.port_type + start_pos = pos1 if ptype == PortTypeEnum.IN.value else pos2 + end_pos = pos2 if ptype == PortTypeEnum.IN.value else pos1 + + padding = 40 + left = end_pos.x() + padding + right = start_pos.x() - padding + path.moveTo(start_pos) + path.lineTo(right, start_pos.y()) + path.lineTo(right, end_pos.y() + n_rect.bottom()) + path.lineTo(left, end_pos.y() + n_rect.bottom()) + path.lineTo(left, end_pos.y()) + path.lineTo(end_pos) + self.setPath(path) + + def _draw_path_vertical(self, start_port, pos1, pos2, path): + """ + Draws the vertical path between ports. + + Args: + start_port (PortItem): port used to draw the starting point. + pos1 (QPointF): start port position. + pos2 (QPointF): end port position. + path (QPainterPath): path to draw. + """ + if self.viewer_pipe_layout() == PipeLayoutEnum.CURVED.value: + ctr_offset_y1, ctr_offset_y2 = pos1.y(), pos2.y() + tangent = abs(ctr_offset_y1 - ctr_offset_y2) + + max_height = start_port.node.boundingRect().height() + tangent = min(tangent, max_height) + if start_port.port_type == PortTypeEnum.IN.value: + ctr_offset_y1 -= tangent + ctr_offset_y2 += tangent + else: + ctr_offset_y1 += tangent + ctr_offset_y2 -= tangent + + ctr_point1 = QtCore.QPointF(pos1.x(), ctr_offset_y1) + ctr_point2 = QtCore.QPointF(pos2.x(), ctr_offset_y2) + path.cubicTo(ctr_point1, ctr_point2, pos2) + self.setPath(path) + elif self.viewer_pipe_layout() == PipeLayoutEnum.ANGLE.value: + ctr_offset_y1, ctr_offset_y2 = pos1.y(), pos2.y() + distance = abs(ctr_offset_y1 - ctr_offset_y2)/2 + if start_port.port_type == PortTypeEnum.IN.value: + ctr_offset_y1 -= distance + ctr_offset_y2 += distance + else: + ctr_offset_y1 += distance + ctr_offset_y2 -= distance + + ctr_point1 = QtCore.QPointF(pos1.x(), ctr_offset_y1) + ctr_point2 = QtCore.QPointF(pos2.x(), ctr_offset_y2) + path.lineTo(ctr_point1) + path.lineTo(ctr_point2) + path.lineTo(pos2) + self.setPath(path) + + def _draw_path_horizontal(self, start_port, pos1, pos2, path): + """ + Draws the horizontal path between ports. + + Args: + start_port (PortItem): port used to draw the starting point. + pos1 (QPointF): start port position. + pos2 (QPointF): end port position. + path (QPainterPath): path to draw. + """ + if self.viewer_pipe_layout() == PipeLayoutEnum.CURVED.value: + ctr_offset_x1, ctr_offset_x2 = pos1.x(), pos2.x() + tangent = abs(ctr_offset_x1 - ctr_offset_x2) + + max_width = start_port.node.boundingRect().width() + tangent = min(tangent, max_width) + if start_port.port_type == PortTypeEnum.IN.value: + ctr_offset_x1 -= tangent + ctr_offset_x2 += tangent + else: + ctr_offset_x1 += tangent + ctr_offset_x2 -= tangent + + ctr_point1 = QtCore.QPointF(ctr_offset_x1, pos1.y()) + ctr_point2 = QtCore.QPointF(ctr_offset_x2, pos2.y()) + path.cubicTo(ctr_point1, ctr_point2, pos2) + self.setPath(path) + elif self.viewer_pipe_layout() == PipeLayoutEnum.ANGLE.value: + ctr_offset_x1, ctr_offset_x2 = pos1.x(), pos2.x() + distance = abs(ctr_offset_x1 - ctr_offset_x2) / 2 + if start_port.port_type == PortTypeEnum.IN.value: + ctr_offset_x1 -= distance + ctr_offset_x2 += distance + else: + ctr_offset_x1 += distance + ctr_offset_x2 -= distance + + ctr_point1 = QtCore.QPointF(ctr_offset_x1, pos1.y()) + ctr_point2 = QtCore.QPointF(ctr_offset_x2, pos2.y()) + path.lineTo(ctr_point1) + path.lineTo(ctr_point2) + path.lineTo(pos2) + self.setPath(path) + + def draw_path(self, start_port, end_port=None, cursor_pos=None): + """ + Draws the path between ports. + + Args: + start_port (PortItem): port used to draw the starting point. + end_port (PortItem): port used to draw the end point. + cursor_pos (QtCore.QPointF): cursor position if specified this + will be the draw end point. + """ + if not start_port: + return + + # get start / end positions. + pos1 = start_port.scenePos() + pos1.setX(pos1.x() + (start_port.boundingRect().width() / 2)) + pos1.setY(pos1.y() + (start_port.boundingRect().height() / 2)) + if cursor_pos: + pos2 = cursor_pos + elif end_port: + pos2 = end_port.scenePos() + pos2.setX(pos2.x() + (start_port.boundingRect().width() / 2)) + pos2.setY(pos2.y() + (start_port.boundingRect().height() / 2)) + else: + return + + # visibility check for connected pipe. + if self.input_port and self.output_port: + is_visible = all([ + self._input_port.isVisible(), + self._output_port.isVisible(), + self._input_port.node.isVisible(), + self._output_port.node.isVisible() + ]) + self.setVisible(is_visible) + + # don't draw pipe if a port or node is not visible. + if not is_visible: + return + + line = QtCore.QLineF(pos1, pos2) + path = QtGui.QPainterPath() + + direction = self.viewer_layout_direction() + + if end_port and not self.viewer().acyclic: + if end_port.node == start_port.node: + if direction is LayoutDirectionEnum.VERTICAL.value: + self._draw_path_cycled_vertical( + start_port, pos1, pos2, path + ) + self._draw_direction_pointer() + return + elif direction is LayoutDirectionEnum.HORIZONTAL.value: + self._draw_path_cycled_horizontal( + start_port, pos1, pos2, path + ) + self._draw_direction_pointer() + return + + path.moveTo(line.x1(), line.y1()) + + if self.viewer_pipe_layout() == PipeLayoutEnum.STRAIGHT.value: + path.lineTo(pos2) + self.setPath(path) + self._draw_direction_pointer() + return + + if direction is LayoutDirectionEnum.VERTICAL.value: + self._draw_path_vertical(start_port, pos1, pos2, path) + elif direction is LayoutDirectionEnum.HORIZONTAL.value: + self._draw_path_horizontal(start_port, pos1, pos2, path) + + self._draw_direction_pointer() + + def reset_path(self): + """ + reset the pipe initial path position. + """ + path = QtGui.QPainterPath(QtCore.QPointF(0.0, 0.0)) + self.setPath(path) + self._draw_direction_pointer() + + def port_from_pos(self, pos, reverse=False): + """ + Args: + pos (QtCore.QPointF): current scene position. + reverse (bool): false to return the nearest port. + + Returns: + PortItem: port item. + """ + inport_pos = self.input_port.scenePos() + outport_pos = self.output_port.scenePos() + input_dist = self._calc_distance(inport_pos, pos) + output_dist = self._calc_distance(outport_pos, pos) + if input_dist < output_dist: + port = self.output_port if reverse else self.input_port + else: + port = self.input_port if reverse else self.output_port + return port + + def viewer(self): + """ + Returns: + NodeViewer: node graph viewer. + """ + if self.scene(): + return self.scene().viewer() + + def viewer_pipe_layout(self): + """ + Returns: + int: pipe layout mode. + """ + viewer = self.viewer() + if viewer: + return viewer.get_pipe_layout() + + def viewer_layout_direction(self): + """ + Returns: + int: graph layout mode. + """ + viewer = self.viewer() + if viewer: + return viewer.get_layout_direction() + + def set_pipe_styling(self, color, width=2, style=0): + """ + Args: + color (list or tuple): (r, g, b, a) values 0-255 + width (int): pipe width. + style (int): pipe style. + """ + pen = self.pen() + pen.setWidth(width) + pen.setColor(QtGui.QColor(*color)) + pen.setStyle(PIPE_STYLES.get(style)) + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + self.setPen(pen) + self.setBrush(QtGui.QBrush(QtCore.Qt.NoBrush)) + + pen = self._dir_pointer.pen() + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + pen.setWidth(width) + pen.setColor(QtGui.QColor(*color)) + self._dir_pointer.setPen(pen) + self._dir_pointer.setBrush(QtGui.QColor(*color).darker(200)) + + def activate(self): + self._active = True + self.set_pipe_styling( + color=PipeEnum.ACTIVE_COLOR.value, + width=3, + style=PipeEnum.DRAW_TYPE_DEFAULT.value + ) + + def active(self): + return self._active + + def highlight(self): + self._highlight = True + self.set_pipe_styling( + color=PipeEnum.HIGHLIGHT_COLOR.value, + width=2, + style=PipeEnum.DRAW_TYPE_DEFAULT.value + ) + + def highlighted(self): + return self._highlight + + def reset(self): + """ + reset the pipe state and styling. + """ + self._active = False + self._highlight = False + self.set_pipe_styling(color=self.color, width=2, style=self.style) + self._draw_direction_pointer() + + def set_connections(self, port1, port2): + """ + Args: + port1 (PortItem): port item object. + port2 (PortItem): port item object. + """ + ports = { + port1.port_type: port1, + port2.port_type: port2 + } + self.input_port = ports[PortTypeEnum.IN.value] + self.output_port = ports[PortTypeEnum.OUT.value] + ports[PortTypeEnum.IN.value].add_pipe(self) + ports[PortTypeEnum.OUT.value].add_pipe(self) + + def disabled(self): + """ + Returns: + bool: true if pipe is a disabled connection. + """ + if self.input_port and self.input_port.node.disabled: + return True + if self.output_port and self.output_port.node.disabled: + return True + return False + + @property + def input_port(self): + return self._input_port + + @input_port.setter + def input_port(self, port): + if isinstance(port, PortItem) or not port: + self._input_port = port + else: + self._input_port = None + + @property + def output_port(self): + return self._output_port + + @output_port.setter + def output_port(self, port): + if isinstance(port, PortItem) or not port: + self._output_port = port + else: + self._output_port = None + + @property + def color(self): + return self._color + + @color.setter + def color(self, color): + self._color = color + + @property + def style(self): + return self._style + + @style.setter + def style(self, style): + self._style = style + + def delete(self): + if self.input_port and self.input_port.connected_pipes: + self.input_port.remove_pipe(self) + if self.output_port and self.output_port.connected_pipes: + self.output_port.remove_pipe(self) + if self.scene(): + self.scene().removeItem(self) + + +class LivePipeItem(PipeItem): + """ + Live Pipe item used for drawing the live connection with the cursor. + """ + + def __init__(self): + super(LivePipeItem, self).__init__() + self.setZValue(Z_VAL_NODE_WIDGET + 1) + + self.color = PipeEnum.ACTIVE_COLOR.value + self.style = PipeEnum.DRAW_TYPE_DASHED.value + self.set_pipe_styling(color=self.color, width=3, style=self.style) + + self.shift_selected = False + + self._idx_pointer = LivePipePolygonItem(self) + self._idx_pointer.setPolygon(self._poly) + self._idx_pointer.setBrush(QtGui.QColor(*self.color).darker(300)) + pen = self._idx_pointer.pen() + pen.setWidth(self.pen().width()) + pen.setColor(self.pen().color()) + pen.setJoinStyle(QtCore.Qt.MiterJoin) + self._idx_pointer.setPen(pen) + + color = self.pen().color() + color.setAlpha(80) + self._idx_text = QtWidgets.QGraphicsTextItem(self) + self._idx_text.setDefaultTextColor(color) + font = self._idx_text.font() + font.setPointSize(7) + self._idx_text.setFont(font) + + def hoverEnterEvent(self, event): + """ + re-implemented back to the base default behaviour or the pipe will + lose it styling when another pipe is selected. + """ + QtWidgets.QGraphicsPathItem.hoverEnterEvent(self, event) + + def draw_path(self, start_port, end_port=None, cursor_pos=None, color=None): + """ + re-implemented to also update the index pointer arrow position. + + Args: + start_port (PortItem): port used to draw the starting point. + end_port (PortItem): port used to draw the end point. + cursor_pos (QtCore.QPointF): cursor position if specified this + will be the draw end point. + color (list[int]): override arrow index pointer color. (r, g, b) + """ + super(LivePipeItem, self).draw_path(start_port, end_port, cursor_pos) + self.draw_index_pointer(start_port, cursor_pos, color) + + def draw_index_pointer(self, start_port, cursor_pos, color=None): + """ + Update the index pointer arrow position and direction when the + live pipe path is redrawn. + + Args: + start_port (PortItem): start port item. + cursor_pos (QtCore.QPoint): cursor scene position. + color (list[int]): override arrow index pointer color. (r, g, b). + """ + text_rect = self._idx_text.boundingRect() + + transform = QtGui.QTransform() + transform.translate(cursor_pos.x(), cursor_pos.y()) + if self.viewer_layout_direction() is LayoutDirectionEnum.VERTICAL.value: + text_pos = ( + cursor_pos.x() + (text_rect.width() / 2.5), + cursor_pos.y() - (text_rect.height() / 2) + ) + if start_port.port_type == PortTypeEnum.OUT.value: + transform.rotate(180) + elif self.viewer_layout_direction() is LayoutDirectionEnum.HORIZONTAL.value: + text_pos = ( + cursor_pos.x() - (text_rect.width() / 2), + cursor_pos.y() - (text_rect.height() * 1.25) + ) + if start_port.port_type == PortTypeEnum.IN.value: + transform.rotate(-90) + else: + transform.rotate(90) + self._idx_text.setPos(*text_pos) + self._idx_text.setPlainText('{}'.format(start_port.name)) + + self._idx_pointer.setPolygon(transform.map(self._poly)) + + pen_color = QtGui.QColor(*PipeEnum.HIGHLIGHT_COLOR.value) + if isinstance(color, (list, tuple)): + pen_color = QtGui.QColor(*color) + + pen = self._idx_pointer.pen() + pen.setColor(pen_color) + self._idx_pointer.setBrush(pen_color.darker(300)) + self._idx_pointer.setPen(pen) + + +class LivePipePolygonItem(QtWidgets.QGraphicsPolygonItem): + """ + Custom live pipe polygon shape. + """ + + def __init__(self, parent): + super(LivePipePolygonItem, self).__init__(parent) + self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True) + + def paint(self, painter, option, widget): + """ + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + painter.save() + painter.setBrush(self.brush()) + painter.setPen(self.pen()) + painter.drawPolygon(self.polygon()) + painter.restore() diff --git a/cuegui/NodeGraphQt/qgraphics/port.py b/cuegui/NodeGraphQt/qgraphics/port.py new file mode 100644 index 000000000..3d43f7888 --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/port.py @@ -0,0 +1,325 @@ +#!/usr/bin/python +from qtpy import QtGui, QtCore, QtWidgets + +from NodeGraphQt.constants import ( + PortTypeEnum, PortEnum, + Z_VAL_PORT, + ITEM_CACHE_MODE) + + +class PortItem(QtWidgets.QGraphicsItem): + """ + Base Port Item. + """ + + def __init__(self, parent=None): + super(PortItem, self).__init__(parent) + self.setAcceptHoverEvents(True) + self.setCacheMode(ITEM_CACHE_MODE) + self.setFlag(self.GraphicsItemFlag.ItemIsSelectable, False) + self.setFlag(self.GraphicsItemFlag.ItemSendsScenePositionChanges, True) + self.setZValue(Z_VAL_PORT) + self._pipes = [] + self._width = PortEnum.SIZE.value + self._height = PortEnum.SIZE.value + self._hovered = False + self._name = 'port' + self._display_name = True + self._color = PortEnum.COLOR.value + self._border_color = PortEnum.BORDER_COLOR.value + self._border_size = 1 + self._port_type = None + self._multi_connection = False + self._locked = False + + def __str__(self): + return '{}.PortItem("{}")'.format(self.__module__, self.name) + + def __repr__(self): + return '{}.PortItem("{}")'.format(self.__module__, self.name) + + def boundingRect(self): + return QtCore.QRectF(0.0, 0.0, + self._width + PortEnum.CLICK_FALLOFF.value, + self._height) + + def paint(self, painter, option, widget): + """ + Draws the circular port. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + painter.save() + + # display falloff collision for debugging + # ---------------------------------------------------------------------- + # pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 80), 0.8) + # pen.setStyle(QtCore.Qt.DotLine) + # painter.setPen(pen) + # painter.drawRect(self.boundingRect()) + # ---------------------------------------------------------------------- + + rect_w = self._width / 1.8 + rect_h = self._height / 1.8 + rect_x = self.boundingRect().center().x() - (rect_w / 2) + rect_y = self.boundingRect().center().y() - (rect_h / 2) + port_rect = QtCore.QRectF(rect_x, rect_y, rect_w, rect_h) + + if self._hovered: + color = QtGui.QColor(*PortEnum.HOVER_COLOR.value) + border_color = QtGui.QColor(*PortEnum.HOVER_BORDER_COLOR.value) + elif self.connected_pipes: + color = QtGui.QColor(*PortEnum.ACTIVE_COLOR.value) + border_color = QtGui.QColor(*PortEnum.ACTIVE_BORDER_COLOR.value) + else: + color = QtGui.QColor(*self.color) + border_color = QtGui.QColor(*self.border_color) + + pen = QtGui.QPen(border_color, 1.8) + painter.setPen(pen) + painter.setBrush(color) + painter.drawEllipse(port_rect) + + if self.connected_pipes and not self._hovered: + painter.setBrush(border_color) + w = port_rect.width() / 2.5 + h = port_rect.height() / 2.5 + rect = QtCore.QRectF(port_rect.center().x() - w / 2, + port_rect.center().y() - h / 2, + w, h) + border_color = QtGui.QColor(*self.border_color) + pen = QtGui.QPen(border_color, 1.6) + painter.setPen(pen) + painter.setBrush(border_color) + painter.drawEllipse(rect) + elif self._hovered: + if self.multi_connection: + pen = QtGui.QPen(border_color, 1.4) + painter.setPen(pen) + painter.setBrush(color) + w = port_rect.width() / 1.8 + h = port_rect.height() / 1.8 + else: + painter.setBrush(border_color) + w = port_rect.width() / 3.5 + h = port_rect.height() / 3.5 + rect = QtCore.QRectF(port_rect.center().x() - w / 2, + port_rect.center().y() - h / 2, + w, h) + painter.drawEllipse(rect) + painter.restore() + + def itemChange(self, change, value): + if change == self.GraphicsItemChange.ItemScenePositionHasChanged: + self.redraw_connected_pipes() + return super(PortItem, self).itemChange(change, value) + + def mousePressEvent(self, event): + super(PortItem, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + super(PortItem, self).mouseReleaseEvent(event) + + def hoverEnterEvent(self, event): + self._hovered = True + super(PortItem, self).hoverEnterEvent(event) + + def hoverLeaveEvent(self, event): + self._hovered = False + super(PortItem, self).hoverLeaveEvent(event) + + def viewer_start_connection(self): + viewer = self.scene().viewer() + viewer.start_live_connection(self) + + def redraw_connected_pipes(self): + if not self.connected_pipes: + return + for pipe in self.connected_pipes: + if self.port_type == PortTypeEnum.IN.value: + pipe.draw_path(self, pipe.output_port) + elif self.port_type == PortTypeEnum.OUT.value: + pipe.draw_path(pipe.input_port, self) + + def add_pipe(self, pipe): + self._pipes.append(pipe) + + def remove_pipe(self, pipe): + self._pipes.remove(pipe) + + @property + def connected_pipes(self): + return self._pipes + + @property + def connected_ports(self): + ports = [] + port_types = { + PortTypeEnum.IN.value: 'output_port', + PortTypeEnum.OUT.value: 'input_port' + } + for pipe in self.connected_pipes: + ports.append(getattr(pipe, port_types[self.port_type])) + return ports + + @property + def hovered(self): + return self._hovered + + @hovered.setter + def hovered(self, value=False): + self._hovered = value + + @property + def node(self): + return self.parentItem() + + @property + def name(self): + return self._name + + @name.setter + def name(self, name=''): + self._name = name.strip() + + @property + def display_name(self): + return self._display_name + + @display_name.setter + def display_name(self, display=True): + self._display_name = display + + @property + def color(self): + return self._color + + @color.setter + def color(self, color=(0, 0, 0, 255)): + self._color = color + self.update() + + @property + def border_color(self): + return self._border_color + + @border_color.setter + def border_color(self, color=(0, 0, 0, 255)): + self._border_color = color + + @property + def border_size(self): + return self._border_size + + @border_size.setter + def border_size(self, size=2): + self._border_size = size + + @property + def locked(self): + return self._locked + + @locked.setter + def locked(self, value=False): + self._locked = value + conn_type = 'multi' if self.multi_connection else 'single' + tooltip = '{}: ({})'.format(self.name, conn_type) + if value: + tooltip += ' (L)' + self.setToolTip(tooltip) + + @property + def multi_connection(self): + return self._multi_connection + + @multi_connection.setter + def multi_connection(self, mode=False): + conn_type = 'multi' if mode else 'single' + self.setToolTip('{}: ({})'.format(self.name, conn_type)) + self._multi_connection = mode + + @property + def port_type(self): + return self._port_type + + @port_type.setter + def port_type(self, port_type): + self._port_type = port_type + + def connect_to(self, port): + if not port: + for pipe in self.connected_pipes: + pipe.delete() + return + if self.scene(): + viewer = self.scene().viewer() + viewer.establish_connection(self, port) + # redraw the ports. + port.update() + self.update() + + def disconnect_from(self, port): + port_types = { + PortTypeEnum.IN.value: 'output_port', + PortTypeEnum.OUT.value: 'input_port' + } + for pipe in self.connected_pipes: + connected_port = getattr(pipe, port_types[self.port_type]) + if connected_port == port: + pipe.delete() + break + # redraw the ports. + port.update() + self.update() + + +class CustomPortItem(PortItem): + """ + Custom port item for drawing custom shape port. + """ + + def __init__(self, parent=None, paint_func=None): + super(CustomPortItem, self).__init__(parent) + self._port_painter = paint_func + + def set_painter(self, func=None): + """ + Set custom paint function for drawing. + + Args: + func (function): paint function. + """ + self._port_painter = func + + def paint(self, painter, option, widget): + """ + Draws the port item. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + if self._port_painter: + rect_w = self._width / 1.8 + rect_h = self._height / 1.8 + rect_x = self.boundingRect().center().x() - (rect_w / 2) + rect_y = self.boundingRect().center().y() - (rect_h / 2) + port_rect = QtCore.QRectF(rect_x, rect_y, rect_w, rect_h) + port_info = { + 'port_type': self.port_type, + 'color': self.color, + 'border_color': self.border_color, + 'multi_connection': self.multi_connection, + 'connected': bool(self.connected_pipes), + 'hovered': self.hovered, + 'locked': self.locked, + } + self._port_painter(painter, port_rect, port_info) + else: + super(CustomPortItem, self).paint(painter, option, widget) diff --git a/cuegui/NodeGraphQt/qgraphics/slicer.py b/cuegui/NodeGraphQt/qgraphics/slicer.py new file mode 100644 index 000000000..791ac132b --- /dev/null +++ b/cuegui/NodeGraphQt/qgraphics/slicer.py @@ -0,0 +1,87 @@ +#!/usr/bin/python +import math + +from qtpy import QtCore, QtGui, QtWidgets + +from NodeGraphQt.constants import Z_VAL_NODE_WIDGET, PipeSlicerEnum + + +class SlicerPipeItem(QtWidgets.QGraphicsPathItem): + """ + Base item used for drawing the pipe connection slicer. + """ + + def __init__(self): + super(SlicerPipeItem, self).__init__() + self.setZValue(Z_VAL_NODE_WIDGET + 2) + + def paint(self, painter, option, widget): + """ + Draws the slicer pipe. + + Args: + painter (QtGui.QPainter): painter used for drawing the item. + option (QtGui.QStyleOptionGraphicsItem): + used to describe the parameters needed to draw. + widget (QtWidgets.QWidget): not used. + """ + color = QtGui.QColor(*PipeSlicerEnum.COLOR.value) + p1 = self.path().pointAtPercent(0) + p2 = self.path().pointAtPercent(1) + size = 6.0 + offset = size / 2 + arrow_size = 4.0 + + painter.save() + painter.setRenderHint(painter.RenderHint.Antialiasing, True) + + font = painter.font() + font.setPointSize(12) + painter.setFont(font) + text = 'slice' + text_x = painter.fontMetrics().width(text) / 2 + text_y = painter.fontMetrics().height() / 1.5 + text_pos = QtCore.QPointF(p1.x() - text_x, p1.y() - text_y) + text_color = QtGui.QColor(*PipeSlicerEnum.COLOR.value) + text_color.setAlpha(80) + painter.setPen(QtGui.QPen( + text_color, PipeSlicerEnum.WIDTH.value, QtCore.Qt.PenStyle.SolidLine + )) + painter.drawText(text_pos, text) + + painter.setPen(QtGui.QPen( + color, PipeSlicerEnum.WIDTH.value, QtCore.Qt.PenStyle.DashDotLine + )) + painter.drawPath(self.path()) + + pen = QtGui.QPen( + color, PipeSlicerEnum.WIDTH.value, QtCore.Qt.PenStyle.SolidLine + ) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) + painter.setPen(pen) + painter.setBrush(color) + + rect = QtCore.QRectF(p1.x() - offset, p1.y() - offset, size, size) + painter.drawEllipse(rect) + + arrow = QtGui.QPolygonF() + arrow.append(QtCore.QPointF(-arrow_size, arrow_size)) + arrow.append(QtCore.QPointF(0.0, -arrow_size * 0.9)) + arrow.append(QtCore.QPointF(arrow_size, arrow_size)) + + transform = QtGui.QTransform() + transform.translate(p2.x(), p2.y()) + radians = math.atan2(p2.y() - p1.y(), + p2.x() - p1.x()) + degrees = math.degrees(radians) - 90 + transform.rotate(degrees) + + painter.drawPolygon(transform.map(arrow)) + painter.restore() + + def draw_path(self, p1, p2): + path = QtGui.QPainterPath() + path.moveTo(p1) + path.lineTo(p2) + self.setPath(path) diff --git a/cuegui/NodeGraphQt/widgets/__init__.py b/cuegui/NodeGraphQt/widgets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cuegui/NodeGraphQt/widgets/actions.py b/cuegui/NodeGraphQt/widgets/actions.py new file mode 100644 index 000000000..54f0b855b --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/actions.py @@ -0,0 +1,112 @@ +#!/usr/bin/python +from qtpy import QtCore, QtWidgets + +from NodeGraphQt.constants import ViewerEnum + + +class BaseMenu(QtWidgets.QMenu): + + def __init__(self, *args, **kwargs): + super(BaseMenu, self).__init__(*args, **kwargs) + # text_color = self.palette().text().color().getRgb() + text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), + ViewerEnum.BACKGROUND_COLOR.value)) + selected_color = self.palette().highlight().color().getRgb() + style_dict = { + 'QMenu': { + 'color': 'rgb({0},{1},{2})'.format(*text_color), + 'background-color': 'rgb({0},{1},{2})'.format( + *ViewerEnum.BACKGROUND_COLOR.value + ), + 'border': '1px solid rgba({0},{1},{2},30)'.format(*text_color), + 'border-radius': '3px', + }, + 'QMenu::item': { + 'padding': '5px 18px 2px', + 'background-color': 'transparent', + }, + 'QMenu::item:selected': { + 'color': 'rgb({0},{1},{2})'.format(*text_color), + 'background-color': 'rgba({0},{1},{2},200)' + .format(*selected_color), + }, + 'QMenu::item:disabled': { + 'color': 'rgba({0},{1},{2},60)'.format(*text_color), + 'background-color': 'rgba({0},{1},{2},200)' + .format(*ViewerEnum.BACKGROUND_COLOR.value), + }, + 'QMenu::separator': { + 'height': '1px', + 'background': 'rgba({0},{1},{2}, 50)'.format(*text_color), + 'margin': '4px 8px', + } + } + stylesheet = '' + for css_class, css in style_dict.items(): + style = '{} {{\n'.format(css_class) + for elm_name, elm_val in css.items(): + style += ' {}:{};\n'.format(elm_name, elm_val) + style += '}\n' + stylesheet += style + self.setStyleSheet(stylesheet) + self.node_class = None + self.graph = None + + # disable for issue #142 + # def hideEvent(self, event): + # super(BaseMenu, self).hideEvent(event) + # for a in self.actions(): + # if hasattr(a, 'node_id'): + # a.node_id = None + + def get_menu(self, name, node_id=None): + for action in self.actions(): + menu = action.menu() + if not menu: + continue + if menu.title() == name: + return menu + if node_id and menu.node_class: + node = menu.graph.get_node_by_id(node_id) + if isinstance(node, menu.node_class): + return menu + + def get_menus(self, node_class): + menus = [] + for action in self.actions(): + menu = action.menu() + if menu.node_class: + if issubclass(menu.node_class, node_class): + menus.append(menu) + return menus + + +class GraphAction(QtWidgets.QAction): + + executed = QtCore.Signal(object) + + def __init__(self, *args, **kwargs): + super(GraphAction, self).__init__(*args, **kwargs) + self.graph = None + self.triggered.connect(self._on_triggered) + + def _on_triggered(self): + self.executed.emit(self.graph) + + def get_action(self, name): + for action in self.qmenu.actions(): + if not action.menu() and action.text() == name: + return action + + +class NodeAction(GraphAction): + + executed = QtCore.Signal(object, object) + + def __init__(self, *args, **kwargs): + super(NodeAction, self).__init__(*args, **kwargs) + self.node_id = None + + def _on_triggered(self): + node = self.graph.get_node_by_id(self.node_id) + self.executed.emit(self.graph, node) diff --git a/cuegui/NodeGraphQt/widgets/dialogs.py b/cuegui/NodeGraphQt/widgets/dialogs.py new file mode 100644 index 000000000..4412f1433 --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/dialogs.py @@ -0,0 +1,92 @@ +import os + +from qtpy import QtWidgets, QtGui, QtCore + +_current_user_directory = os.path.expanduser('~') + + +def _set_dir(file): + global _current_user_directory + if os.path.isdir(file): + _current_user_directory = file + elif os.path.isfile(file): + _current_user_directory = os.path.split(file)[0] + + +class FileDialog(object): + + @staticmethod + def getSaveFileName(parent=None, title='Save File', file_dir=None, + ext_filter='*'): + if not file_dir: + file_dir = _current_user_directory + file_dlg = QtWidgets.QFileDialog.getSaveFileName( + parent, title, file_dir, ext_filter) + file = file_dlg[0] or None + if file: + _set_dir(file) + return file_dlg + + @staticmethod + def getOpenFileName(parent=None, title='Open File', file_dir=None, + ext_filter='*'): + if not file_dir: + file_dir = _current_user_directory + file_dlg = QtWidgets.QFileDialog.getOpenFileName( + parent, title, file_dir, ext_filter) + file = file_dlg[0] or None + if file: + _set_dir(file) + return file_dlg + + +class BaseDialog(object): + + @staticmethod + def message_dialog(parent=None, text='', title='Message', dialog_icon=None, + custom_icon=None): + dlg = QtWidgets.QMessageBox(parent=parent) + dlg.setWindowTitle(title) + dlg.setInformativeText(text) + dlg.setStandardButtons(QtWidgets.QMessageBox.Ok) + + if custom_icon: + pixmap = QtGui.QPixmap(custom_icon).scaledToHeight( + 32, QtCore.Qt.SmoothTransformation + ) + dlg.setIconPixmap(pixmap) + else: + if dialog_icon == 'information': + dlg.setIcon(dlg.Information) + elif dialog_icon == 'warning': + dlg.setIcon(dlg.Warning) + elif dialog_icon == 'critical': + dlg.setIcon(dlg.Critical) + + dlg.exec_() + + @staticmethod + def question_dialog(parent=None, text='', title='Are you sure?', + dialog_icon=None, custom_icon=None): + dlg = QtWidgets.QMessageBox(parent=parent) + dlg.setWindowTitle(title) + dlg.setInformativeText(text) + dlg.setStandardButtons( + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No + ) + + if custom_icon: + pixmap = QtGui.QPixmap(custom_icon).scaledToHeight( + 32, QtCore.Qt.SmoothTransformation + ) + dlg.setIconPixmap(pixmap) + else: + if dialog_icon == 'information': + dlg.setIcon(dlg.Information) + elif dialog_icon == 'warning': + dlg.setIcon(dlg.Warning) + elif dialog_icon == 'critical': + dlg.setIcon(dlg.Critical) + + result = dlg.exec_() + return bool(result == QtWidgets.QMessageBox.Yes) diff --git a/cuegui/NodeGraphQt/widgets/icons/node_base.png b/cuegui/NodeGraphQt/widgets/icons/node_base.png new file mode 100644 index 0000000000000000000000000000000000000000..cb18157c4038b931c01a585a37a2bf4fe6988f1c GIT binary patch literal 17542 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 BbC>`C literal 0 HcmV?d00001 diff --git a/cuegui/NodeGraphQt/widgets/node_graph.py b/cuegui/NodeGraphQt/widgets/node_graph.py new file mode 100644 index 000000000..66880e252 --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/node_graph.py @@ -0,0 +1,125 @@ +from qtpy import QtWidgets, QtGui + +from NodeGraphQt.constants import ( + NodeEnum, ViewerEnum, ViewerNavEnum +) + +from NodeGraphQt.widgets.viewer_nav import NodeNavigationWidget + + +class NodeGraphWidget(QtWidgets.QTabWidget): + + def __init__(self, parent=None): + super(NodeGraphWidget, self).__init__(parent) + self.setTabsClosable(True) + self.setTabBarAutoHide(True) + bg_color = QtGui.QColor( + *ViewerEnum.BACKGROUND_COLOR.value).darker(120).getRgb() + text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), bg_color)) + style_dict = { + 'QWidget': { + 'background-color': 'rgb({0},{1},{2})'.format( + *ViewerEnum.BACKGROUND_COLOR.value + ), + }, + 'QTabWidget::pane': { + 'background': 'rgb({0},{1},{2})'.format( + *ViewerEnum.BACKGROUND_COLOR.value + ), + 'border': '0px', + 'border-top': '0px solid rgb({0},{1},{2})'.format(*bg_color), + }, + 'QTabBar::tab': { + 'background': 'rgb({0},{1},{2})'.format(*bg_color), + 'border': '0px solid black', + 'color': 'rgba({0},{1},{2},30)'.format(*text_color), + 'min-width': '10px', + 'padding': '10px 20px', + }, + 'QTabBar::tab:selected': { + 'color': 'rgb({0},{1},{2})'.format(*text_color), + 'background': 'rgb({0},{1},{2})'.format( + *ViewerNavEnum.BACKGROUND_COLOR.value + ), + 'border-top': '1px solid rgb({0},{1},{2})' + .format(*NodeEnum.SELECTED_BORDER_COLOR.value), + }, + 'QTabBar::tab:hover': { + 'color': 'rgb({0},{1},{2})'.format(*text_color), + 'border-top': '1px solid rgb({0},{1},{2})' + .format(*NodeEnum.SELECTED_BORDER_COLOR.value), + } + } + stylesheet = '' + for css_class, css in style_dict.items(): + style = '{} {{\n'.format(css_class) + for elm_name, elm_val in css.items(): + style += ' {}:{};\n'.format(elm_name, elm_val) + style += '}\n' + stylesheet += style + self.setStyleSheet(stylesheet) + + def add_viewer(self, viewer, name, node_id): + self.addTab(viewer, name) + index = self.indexOf(viewer) + self.setTabToolTip(index, node_id) + self.setCurrentIndex(index) + + def remove_viewer(self, viewer): + index = self.indexOf(viewer) + self.removeTab(index) + + +class SubGraphWidget(QtWidgets.QWidget): + + def __init__(self, parent=None, graph=None): + super(SubGraphWidget, self).__init__(parent) + self._graph = graph + self._navigator = NodeNavigationWidget() + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(1) + self._layout.addWidget(self._navigator) + + self._viewer_widgets = {} + self._viewer_current = None + + @property + def navigator(self): + return self._navigator + + def add_viewer(self, viewer, name, node_id): + if viewer in self._viewer_widgets: + return + + if self._viewer_current: + self.hide_viewer(self._viewer_current) + + self._navigator.add_label_item(name, node_id) + self._layout.addWidget(viewer) + self._viewer_widgets[viewer] = node_id + self._viewer_current = viewer + self._viewer_current.show() + + def remove_viewer(self, viewer=None): + if viewer is None and self._viewer_current: + viewer = self._viewer_current + node_id = self._viewer_widgets.pop(viewer) + self._navigator.remove_label_item(node_id) + self._layout.removeWidget(viewer) + viewer.deleteLater() + + def hide_viewer(self, viewer): + self._layout.removeWidget(viewer) + viewer.hide() + + def show_viewer(self, viewer): + if viewer == self._viewer_current: + self._viewer_current.show() + return + if viewer in self._viewer_widgets: + if self._viewer_current: + self.hide_viewer(self._viewer_current) + self._layout.addWidget(viewer) + self._viewer_current = viewer + self._viewer_current.show() diff --git a/cuegui/NodeGraphQt/widgets/node_widgets.py b/cuegui/NodeGraphQt/widgets/node_widgets.py new file mode 100644 index 000000000..333df2d7b --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/node_widgets.py @@ -0,0 +1,448 @@ +#!/usr/bin/python +from qtpy import QtCore, QtWidgets + +from NodeGraphQt.constants import ViewerEnum, Z_VAL_NODE_WIDGET +from NodeGraphQt.errors import NodeWidgetError + + +class _NodeGroupBox(QtWidgets.QGroupBox): + + def __init__(self, label, parent=None): + super(_NodeGroupBox, self).__init__(parent) + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(1) + self.setTitle(label) + + def setTitle(self, text): + margin = (0, 2, 0, 0) if text else (0, 0, 0, 0) + self.layout().setContentsMargins(*margin) + super(_NodeGroupBox, self).setTitle(text) + + def setTitleAlign(self, align='center'): + text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), + ViewerEnum.BACKGROUND_COLOR.value)) + style_dict = { + 'QGroupBox': { + 'background-color': 'rgba(0, 0, 0, 0)', + 'border': '0px solid rgba(0, 0, 0, 0)', + 'margin-top': '1px', + 'padding-bottom': '2px', + 'padding-left': '1px', + 'padding-right': '1px', + 'font-size': '8pt', + }, + 'QGroupBox::title': { + 'subcontrol-origin': 'margin', + 'subcontrol-position': 'top center', + 'color': 'rgba({0}, {1}, {2}, 100)'.format(*text_color), + 'padding': '0px', + } + } + if self.title(): + style_dict['QGroupBox']['padding-top'] = '14px' + else: + style_dict['QGroupBox']['padding-top'] = '2px' + + if align == 'center': + style_dict['QGroupBox::title']['subcontrol-position'] = 'top center' + elif align == 'left': + style_dict['QGroupBox::title']['subcontrol-position'] += 'top left' + style_dict['QGroupBox::title']['margin-left'] = '4px' + elif align == 'right': + style_dict['QGroupBox::title']['subcontrol-position'] += 'top right' + style_dict['QGroupBox::title']['margin-right'] = '4px' + stylesheet = '' + for css_class, css in style_dict.items(): + style = '{} {{\n'.format(css_class) + for elm_name, elm_val in css.items(): + style += ' {}:{};\n'.format(elm_name, elm_val) + style += '}\n' + stylesheet += style + self.setStyleSheet(stylesheet) + + def add_node_widget(self, widget): + self.layout().addWidget(widget) + + def get_node_widget(self): + return self.layout().itemAt(0).widget() + + +class NodeBaseWidget(QtWidgets.QGraphicsProxyWidget): + """ + This is the main wrapper class that allows a ``QtWidgets.QWidget`` to be + added in a :class:`NodeGraphQt.BaseNode` object. + + .. inheritance-diagram:: NodeGraphQt.NodeBaseWidget + :parts: 1 + + Args: + parent (NodeGraphQt.BaseNode.view): parent node view. + name (str): property name for the parent node. + label (str): label text above the embedded widget. + """ + + value_changed = QtCore.Signal(str, object) + """ + Signal triggered when the ``value`` attribute has changed. + + (This is connected to the :meth: `BaseNode.set_property` function when the + widget is added into the node.) + + :parameters: str, object + :emits: property name, propety value + """ + + def __init__(self, parent=None, name=None, label=''): + super(NodeBaseWidget, self).__init__(parent) + self.setZValue(Z_VAL_NODE_WIDGET) + self._name = name + self._label = label + self._node = None + + def setToolTip(self, tooltip): + tooltip = tooltip.replace('\n', '
    ') + tooltip = '{}
    {}'.format(self.get_name(), tooltip) + super(NodeBaseWidget, self).setToolTip(tooltip) + + def on_value_changed(self, *args, **kwargs): + """ + This is the slot function that + Emits the widgets current :meth:`NodeBaseWidget.value` with the + :attr:`NodeBaseWidget.value_changed` signal. + + Args: + args: not used. + kwargs: not used. + + Emits: + str, object: , + """ + self.value_changed.emit(self.get_name(), self.get_value()) + + @property + def type_(self): + """ + Returns the node widget type. + + Returns: + str: widget type. + """ + return str(self.__class__.__name__) + + @property + def node(self): + """ + Returns the node object this widget is embedded in. + (This will return ``None`` if the widget has not been added to + the node yet.) + + Returns: + NodeGraphQt.BaseNode: parent node. + """ + return self._node + + def get_icon(self, name): + """ + Returns the default icon from the Qt framework. + + Returns: + str: icon name. + """ + return self.style().standardIcon(QtWidgets.QStyle.StandardPixmap(name)) + + def get_name(self): + """ + Returns the parent node property name. + + Returns: + str: property name. + """ + return self._name + + def set_name(self, name): + """ + Set the property name for the parent node. + + Important: + The property name must be set before the widget is added to + the node. + + Args: + name (str): property name. + """ + if not name: + return + if self.node: + raise NodeWidgetError( + 'Can\'t set property name widget already added to a Node' + ) + self._name = name + + def get_value(self): + """ + Returns the widgets current value. + + You must re-implement this property to if you're using a custom widget. + + Returns: + str: current property value. + """ + raise NotImplementedError + + def set_value(self, text): + """ + Sets the widgets current value. + + You must re-implement this property to if you're using a custom widget. + + Args: + text (str): new text value. + """ + raise NotImplementedError + + def get_custom_widget(self): + """ + Returns the embedded QWidget used in the node. + + Returns: + QtWidgets.QWidget: nested QWidget + """ + widget = self.widget() + return widget.get_node_widget() + + def set_custom_widget(self, widget): + """ + Set the custom QWidget used in the node. + + Args: + widget (QtWidgets.QWidget): custom. + """ + if self.widget(): + raise NodeWidgetError('Custom node widget already set.') + group = _NodeGroupBox(self._label) + group.add_node_widget(widget) + self.setWidget(group) + + def get_label(self): + """ + Returns the label text displayed above the embedded node widget. + + Returns: + str: label text. + """ + return self._label + + def set_label(self, label=''): + """ + Sets the label text above the embedded widget. + + Args: + label (str): new label ext. + """ + if self.widget(): + self.widget().setTitle(label) + self._label = label + + +class NodeComboBox(NodeBaseWidget): + """ + Displays as a ``QComboBox`` in a node. + + .. inheritance-diagram:: NodeGraphQt.widgets.node_widgets.NodeComboBox + :parts: 1 + + .. note:: + `To embed a` ``QComboBox`` `in a node see func:` + :meth:`NodeGraphQt.BaseNode.add_combo_menu` + """ + + def __init__(self, parent=None, name='', label='', items=None): + super(NodeComboBox, self).__init__(parent, name, label) + self.setZValue(Z_VAL_NODE_WIDGET + 1) + combo = QtWidgets.QComboBox() + combo.setMinimumHeight(24) + combo.addItems(items or []) + combo.currentIndexChanged.connect(self.on_value_changed) + combo.clearFocus() + self.set_custom_widget(combo) + + @property + def type_(self): + return 'ComboNodeWidget' + + def get_value(self): + """ + Returns the widget current text. + + Returns: + str: current text. + """ + combo_widget = self.get_custom_widget() + return str(combo_widget.currentText()) + + def set_value(self, text=''): + combo_widget = self.get_custom_widget() + if type(text) is list: + combo_widget.clear() + combo_widget.addItems(text) + return + if text != self.get_value(): + index = combo_widget.findText(text, QtCore.Qt.MatchFlag.MatchExactly) + combo_widget.setCurrentIndex(index) + + def add_item(self, item): + combo_widget = self.get_custom_widget() + combo_widget.addItem(item) + + def add_items(self, items=None): + if items: + combo_widget = self.get_custom_widget() + combo_widget.addItems(items) + + def all_items(self): + combo_widget = self.get_custom_widget() + return [combo_widget.itemText(i) for i in range(combo_widget.count())] + + def sort_items(self, reversed=False): + items = sorted(self.all_items(), reverse=reversed) + combo_widget = self.get_custom_widget() + combo_widget.clear() + combo_widget.addItems(items) + + def clear(self): + combo_widget = self.get_custom_widget() + combo_widget.clear() + + +class NodeLineEdit(NodeBaseWidget): + """ + Displays as a ``QLineEdit`` in a node. + + .. inheritance-diagram:: NodeGraphQt.widgets.node_widgets.NodeLineEdit + :parts: 1 + + .. note:: + `To embed a` ``QLineEdit`` `in a node see func:` + :meth:`NodeGraphQt.BaseNode.add_text_input` + """ + + def __init__(self, parent=None, name='', label='', text='', placeholder_text=''): + super(NodeLineEdit, self).__init__(parent, name, label) + bg_color = ViewerEnum.BACKGROUND_COLOR.value + text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), + bg_color)) + text_sel_color = text_color + style_dict = { + 'QLineEdit': { + 'background': 'rgba({0},{1},{2},20)'.format(*bg_color), + 'border': '1px solid rgb({0},{1},{2})' + .format(*ViewerEnum.GRID_COLOR.value), + 'border-radius': '3px', + 'color': 'rgba({0},{1},{2},150)'.format(*text_color), + 'selection-background-color': 'rgba({0},{1},{2},100)' + .format(*text_sel_color), + } + } + stylesheet = '' + for css_class, css in style_dict.items(): + style = '{} {{\n'.format(css_class) + for elm_name, elm_val in css.items(): + style += ' {}:{};\n'.format(elm_name, elm_val) + style += '}\n' + stylesheet += style + ledit = QtWidgets.QLineEdit() + ledit.setText(text) + ledit.setPlaceholderText(placeholder_text) + ledit.setStyleSheet(stylesheet) + ledit.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + ledit.editingFinished.connect(self.on_value_changed) + ledit.clearFocus() + self.set_custom_widget(ledit) + self.widget().setMaximumWidth(140) + + @property + def type_(self): + return 'LineEditNodeWidget' + + def get_value(self): + """ + Returns the widgets current text. + + Returns: + str: current text. + """ + return str(self.get_custom_widget().text()) + + def set_value(self, text=''): + """ + Sets the widgets current text. + + Args: + text (str): new text. + """ + if text != self.get_value(): + self.get_custom_widget().setText(text) + self.on_value_changed() + + +class NodeCheckBox(NodeBaseWidget): + """ + Displays as a ``QCheckBox`` in a node. + + .. inheritance-diagram:: NodeGraphQt.widgets.node_widgets.NodeCheckBox + :parts: 1 + + .. note:: + `To embed a` ``QCheckBox`` `in a node see func:` + :meth:`NodeGraphQt.BaseNode.add_checkbox` + """ + + def __init__(self, parent=None, name='', label='', text='', state=False): + super(NodeCheckBox, self).__init__(parent, name, label) + _cbox = QtWidgets.QCheckBox(text) + text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), + ViewerEnum.BACKGROUND_COLOR.value)) + style_dict = { + 'QCheckBox': { + 'color': 'rgba({0},{1},{2},150)'.format(*text_color), + } + } + stylesheet = '' + for css_class, css in style_dict.items(): + style = '{} {{\n'.format(css_class) + for elm_name, elm_val in css.items(): + style += ' {}:{};\n'.format(elm_name, elm_val) + style += '}\n' + stylesheet += style + _cbox.setStyleSheet(stylesheet) + _cbox.setChecked(state) + _cbox.setMinimumWidth(80) + font = _cbox.font() + font.setPointSize(11) + _cbox.setFont(font) + _cbox.stateChanged.connect(self.on_value_changed) + self.set_custom_widget(_cbox) + self.widget().setMaximumWidth(140) + + @property + def type_(self): + return 'CheckboxNodeWidget' + + def get_value(self): + """ + Returns the widget checked state. + + Returns: + bool: checked state. + """ + return self.get_custom_widget().isChecked() + + def set_value(self, state=False): + """ + Sets the widget checked state. + + Args: + state (bool): check state. + """ + if state != self.get_value(): + self.get_custom_widget().setChecked(state) diff --git a/cuegui/NodeGraphQt/widgets/scene.py b/cuegui/NodeGraphQt/widgets/scene.py new file mode 100644 index 000000000..979bbbd2c --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/scene.py @@ -0,0 +1,171 @@ +#!/usr/bin/python +from qtpy import QtGui, QtCore, QtWidgets + +from NodeGraphQt.constants import ViewerEnum + + +class NodeScene(QtWidgets.QGraphicsScene): + + def __init__(self, parent=None): + super(NodeScene, self).__init__(parent) + self._grid_mode = ViewerEnum.GRID_DISPLAY_LINES.value + self._grid_color = ViewerEnum.GRID_COLOR.value + self._bg_color = ViewerEnum.BACKGROUND_COLOR.value + self.setBackgroundBrush(QtGui.QColor(*self._bg_color)) + + def __repr__(self): + cls_name = str(self.__class__.__name__) + return '<{}("{}") object at {}>'.format( + cls_name, self.viewer(), hex(id(self))) + + # def _draw_text(self, painter, pen): + # font = QtGui.QFont() + # font.setPixelSize(48) + # painter.setFont(font) + # parent = self.viewer() + # pos = QtCore.QPoint(20, parent.height() - 20) + # painter.setPen(pen) + # painter.drawText(parent.mapToScene(pos), 'Not Editable') + + def _draw_grid(self, painter, rect, pen, grid_size): + """ + draws the grid lines in the scene. + + Args: + painter (QtGui.QPainter): painter object. + rect (QtCore.QRectF): rect object. + pen (QtGui.QPen): pen object. + grid_size (int): grid size. + """ + left = int(rect.left()) + right = int(rect.right()) + top = int(rect.top()) + bottom = int(rect.bottom()) + + first_left = left - (left % grid_size) + first_top = top - (top % grid_size) + + lines = [] + lines.extend([ + QtCore.QLineF(x, top, x, bottom) + for x in range(first_left, right, grid_size) + ]) + lines.extend([ + QtCore.QLineF(left, y, right, y) + for y in range(first_top, bottom, grid_size)] + ) + + painter.setPen(pen) + painter.drawLines(lines) + + def _draw_dots(self, painter, rect, pen, grid_size): + """ + draws the grid dots in the scene. + + Args: + painter (QtGui.QPainter): painter object. + rect (QtCore.QRectF): rect object. + pen (QtGui.QPen): pen object. + grid_size (int): grid size. + """ + zoom = self.viewer().get_zoom() + if zoom < 0: + grid_size = int(abs(zoom) / 0.3 + 1) * grid_size + + left = int(rect.left()) + right = int(rect.right()) + top = int(rect.top()) + bottom = int(rect.bottom()) + + first_left = left - (left % grid_size) + first_top = top - (top % grid_size) + + pen.setWidth(grid_size / 10) + painter.setPen(pen) + + [painter.drawPoint(int(x), int(y)) + for x in range(first_left, right, grid_size) + for y in range(first_top, bottom, grid_size)] + + def drawBackground(self, painter, rect): + super(NodeScene, self).drawBackground(painter, rect) + + painter.save() + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, False) + painter.setBrush(self.backgroundBrush()) + + if self._grid_mode is ViewerEnum.GRID_DISPLAY_DOTS.value: + pen = QtGui.QPen(QtGui.QColor(*self.grid_color), 0.65) + self._draw_dots(painter, rect, pen, ViewerEnum.GRID_SIZE.value) + + elif self._grid_mode is ViewerEnum.GRID_DISPLAY_LINES.value: + zoom = self.viewer().get_zoom() + if zoom > -0.5: + pen = QtGui.QPen(QtGui.QColor(*self.grid_color), 0.65) + self._draw_grid( + painter, rect, pen, ViewerEnum.GRID_SIZE.value + ) + + color = QtGui.QColor(*self._bg_color).darker(200) + if zoom < -0.0: + color = color.darker(100 - int(zoom * 110)) + pen = QtGui.QPen(color, 0.65) + self._draw_grid( + painter, rect, pen, ViewerEnum.GRID_SIZE.value * 8 + ) + + painter.restore() + + def mousePressEvent(self, event): + selected_nodes = self.viewer().selected_nodes() + if self.viewer(): + self.viewer().sceneMousePressEvent(event) + super(NodeScene, self).mousePressEvent(event) + keep_selection = any([ + event.button() == QtCore.Qt.MouseButton.MiddleButton, + event.button() == QtCore.Qt.MouseButton.RightButton, + event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier + ]) + if keep_selection: + for node in selected_nodes: + node.setSelected(True) + + def mouseMoveEvent(self, event): + if self.viewer(): + self.viewer().sceneMouseMoveEvent(event) + super(NodeScene, self).mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self.viewer(): + self.viewer().sceneMouseReleaseEvent(event) + super(NodeScene, self).mouseReleaseEvent(event) + + def viewer(self): + return self.views()[0] if self.views() else None + + @property + def grid_mode(self): + return self._grid_mode + + @grid_mode.setter + def grid_mode(self, mode=None): + if mode is None: + mode = ViewerEnum.GRID_DISPLAY_LINES.value + self._grid_mode = mode + + @property + def grid_color(self): + return self._grid_color + + @grid_color.setter + def grid_color(self, color=(0, 0, 0)): + self._grid_color = color + + @property + def background_color(self): + return self._bg_color + + @background_color.setter + def background_color(self, color=(0, 0, 0)): + self._bg_color = color + self.setBackgroundBrush(QtGui.QColor(*self._bg_color)) diff --git a/cuegui/NodeGraphQt/widgets/tab_search.py b/cuegui/NodeGraphQt/widgets/tab_search.py new file mode 100644 index 000000000..3e60a5521 --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/tab_search.py @@ -0,0 +1,311 @@ +#!/usr/bin/python +import re +from collections import OrderedDict + +from qtpy import QtCore, QtWidgets, QtGui + +from NodeGraphQt.constants import ViewerEnum, ViewerNavEnum + + +class TabSearchCompleter(QtWidgets.QCompleter): + """ + QCompleter adapted from: + https://stackoverflow.com/questions/5129211/qcompleter-custom-completion-rules + """ + + def __init__(self, nodes=None, parent=None): + super(TabSearchCompleter, self).__init__(nodes, parent) + self.setCompletionMode(self.CompletionMode.PopupCompletion) + self.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + self._local_completion_prefix = '' + self._using_orig_model = False + self._source_model = None + self._filter_model = None + + def splitPath(self, path): + self._local_completion_prefix = path + self.updateModel() + + if self._filter_model.rowCount() == 0: + self._using_orig_model = False + self._filter_model.setSourceModel(QtCore.QStringListModel([])) + return [] + return [] + + def updateModel(self): + if not self._using_orig_model: + self._filter_model.setSourceModel(self._source_model) + # # https://doc.qt.io/qtforpython-6/overviews/qtcore-changes-qt6.html#the-qregularexpression-class + # pattern = QtCore.QRegExp(self._local_completion_prefix, + # QtCore.Qt.CaseSensitivity.CaseInsensitive, + # QtCore.QRegExp.FixedString) + # self._filter_model.setFilterRegExp(pattern) + # TODO: review these changes + self._filter_model.setFilterCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + self._filter_model.setFilterFixedString(self._local_completion_prefix) + + def setModel(self, model): + self._source_model = model + self._filter_model = QtCore.QSortFilterProxyModel(self) + self._filter_model.setSourceModel(self._source_model) + super(TabSearchCompleter, self).setModel(self._filter_model) + self._using_orig_model = True + + +class TabSearchLineEditWidget(QtWidgets.QLineEdit): + + tab_pressed = QtCore.Signal() + + def __init__(self, parent=None): + super(TabSearchLineEditWidget, self).__init__(parent) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_MacShowFocusRect, 0) + self.setMinimumSize(200, 22) + # text_color = self.palette().text().color().getRgb() + text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), + ViewerEnum.BACKGROUND_COLOR.value)) + selected_color = self.palette().highlight().color().getRgb() + style_dict = { + 'QLineEdit': { + 'color': 'rgb({0},{1},{2})'.format(*text_color), + 'border': '1px solid rgb({0},{1},{2})'.format( + *selected_color + ), + 'border-radius': '3px', + 'padding': '2px 4px', + 'margin': '2px 4px 8px 4px', + 'background': 'rgb({0},{1},{2})'.format( + *ViewerNavEnum.BACKGROUND_COLOR.value + ), + 'selection-background-color': 'rgba({0},{1},{2},200)' + .format(*selected_color), + } + } + stylesheet = '' + for css_class, css in style_dict.items(): + style = '{} {{\n'.format(css_class) + for elm_name, elm_val in css.items(): + style += ' {}:{};\n'.format(elm_name, elm_val) + style += '}\n' + stylesheet += style + self.setStyleSheet(stylesheet) + + def keyPressEvent(self, event): + super(TabSearchLineEditWidget, self).keyPressEvent(event) + if event.key() == QtCore.Qt.Key.Key_Tab: + self.tab_pressed.emit() + + +class TabSearchMenuWidget(QtWidgets.QMenu): + + search_submitted = QtCore.Signal(str) + + def __init__(self, node_dict=None): + super(TabSearchMenuWidget, self).__init__() + + self.line_edit = TabSearchLineEditWidget() + self.line_edit.tab_pressed.connect(self._close) + + self._node_dict = node_dict or {} + if self._node_dict: + self._generate_items_from_node_dict() + + search_widget = QtWidgets.QWidgetAction(self) + search_widget.setDefaultWidget(self.line_edit) + self.addAction(search_widget) + + # text_color = self.palette().text().color().getRgb() + text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), + ViewerEnum.BACKGROUND_COLOR.value)) + selected_color = self.palette().highlight().color().getRgb() + style_dict = { + 'QMenu': { + 'color': 'rgb({0},{1},{2})'.format(*text_color), + 'background-color': 'rgb({0},{1},{2})'.format( + *ViewerEnum.BACKGROUND_COLOR.value + ), + 'border': '1px solid rgba({0},{1},{2},30)'.format(*text_color), + 'border-radius': '3px', + }, + 'QMenu::item': { + 'padding': '5px 18px 2px', + 'background-color': 'transparent', + }, + 'QMenu::item:selected': { + 'color': 'rgb({0},{1},{2})'.format(*text_color), + 'background-color': 'rgba({0},{1},{2},200)' + .format(*selected_color), + }, + 'QMenu::separator': { + 'height': '1px', + 'background': 'rgba({0},{1},{2}, 50)'.format(*text_color), + 'margin': '4px 8px', + } + } + self._menu_stylesheet = '' + for css_class, css in style_dict.items(): + style = '{} {{\n'.format(css_class) + for elm_name, elm_val in css.items(): + style += ' {}:{};\n'.format(elm_name, elm_val) + style += '}\n' + self._menu_stylesheet += style + self.setStyleSheet(self._menu_stylesheet) + + self._actions = {} + self._menus = {} + self._searched_actions = [] + + self._block_submit = False + + self.rebuild = False + + self._wire_signals() + + def __repr__(self): + return '<{} at {}>'.format(self.__class__.__name__, hex(id(self))) + + def keyPressEvent(self, event): + super(TabSearchMenuWidget, self).keyPressEvent(event) + self.line_edit.keyPressEvent(event) + + @staticmethod + def _fuzzy_finder(key, collection): + suggestions = [] + pattern = '.*?'.join(key.lower()) + regex = re.compile(pattern) + for item in collection: + match = regex.search(item.lower()) + if match: + suggestions.append((len(match.group()), match.start(), item)) + + return [x for _, _, x in sorted(suggestions)] + + def _wire_signals(self): + self.line_edit.returnPressed.connect(self._on_search_submitted) + self.line_edit.textChanged.connect(self._on_text_changed) + + def _on_text_changed(self, text): + self._clear_actions() + + if not text: + self._set_menu_visible(True) + return + + self._set_menu_visible(False) + + action_names = self._fuzzy_finder(text, self._actions.keys()) + + self._searched_actions = [self._actions[name] for name in action_names] + self.addActions(self._searched_actions) + + if self._searched_actions: + self.setActiveAction(self._searched_actions[0]) + + def _clear_actions(self): + for action in self._searched_actions: + self.removeAction(action) + action.triggered.connect(self._on_search_submitted) + del self._searched_actions[:] + + def _set_menu_visible(self, visible): + for menu in self._menus.values(): + menu.menuAction().setVisible(visible) + + def _close(self): + self._set_menu_visible(False) + self.setVisible(False) + self.menuAction().setVisible(False) + self._block_submit = True + + def _show(self): + self.line_edit.setText("") + self.line_edit.setFocus() + self._set_menu_visible(True) + self._block_submit = False + self.exec_(QtGui.QCursor.pos()) + + def _on_search_submitted(self): + if not self._block_submit: + action = self.sender() + if type(action) is not QtWidgets.QAction: + if len(self._searched_actions) > 0: + action = self._searched_actions[0] + else: + self._close() + return + + text = action.text() + node_type = self._node_dict.get(text) + if node_type: + self.search_submitted.emit(node_type) + + self._close() + + def build_menu_tree(self): + node_types = sorted(self._node_dict.values()) + node_names = sorted(self._node_dict.keys()) + menu_tree = OrderedDict() + + max_depth = 0 + for node_type in node_types: + trees = '.'.join(node_type.split('.')[:-1]).split('::') + for depth, menu_name in enumerate(trees): + new_menu = None + menu_path = '::'.join(trees[:depth + 1]) + if depth in menu_tree.keys(): + if menu_name not in menu_tree[depth].keys(): + new_menu = QtWidgets.QMenu(menu_name) + new_menu.keyPressEvent = self.keyPressEvent + new_menu.setStyleSheet(self._menu_stylesheet) + menu_tree[depth][menu_path] = new_menu + else: + new_menu = QtWidgets.QMenu(menu_name) + new_menu.setStyleSheet(self._menu_stylesheet) + menu_tree[depth] = {menu_path: new_menu} + if depth > 0 and new_menu: + new_menu.parentPath = '::'.join(trees[:depth]) + + max_depth = max(max_depth, depth) + + for i in range(max_depth+1): + menus = menu_tree[i] + for menu_path, menu in menus.items(): + self._menus[menu_path] = menu + if i == 0: + self.addMenu(menu) + else: + parent_menu = self._menus[menu.parentPath] + parent_menu.addMenu(menu) + + for name in node_names: + action = QtWidgets.QAction(name, self) + action.setText(name) + action.triggered.connect(self._on_search_submitted) + self._actions[name] = action + + menu_name = self._node_dict[name] + menu_path = '.'.join(menu_name.split('.')[:-1]) + + if menu_path in self._menus.keys(): + self._menus[menu_path].addAction(action) + else: + self.addAction(action) + + def set_nodes(self, node_dict=None): + if not self._node_dict or self.rebuild: + self._node_dict.clear() + self._clear_actions() + self._set_menu_visible(False) + for menu in self._menus.values(): + self.removeAction(menu.menuAction()) + self._actions.clear() + self._menus.clear() + for name, node_types in node_dict.items(): + if len(node_types) == 1: + self._node_dict[name] = node_types[0] + continue + for node_id in node_types: + self._node_dict['{} ({})'.format(name, node_id)] = node_id + self.build_menu_tree() + self.rebuild = False + + self._show() diff --git a/cuegui/NodeGraphQt/widgets/viewer.py b/cuegui/NodeGraphQt/widgets/viewer.py new file mode 100644 index 000000000..87584b81b --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/viewer.py @@ -0,0 +1,1653 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +import math +from distutils.version import LooseVersion + +from qtpy import QtGui, QtCore, QtWidgets + +from NodeGraphQt.base.menu import BaseMenu +from NodeGraphQt.constants import ( + LayoutDirectionEnum, + PortTypeEnum, + PipeEnum, + PipeLayoutEnum, + ViewerEnum, + Z_VAL_PIPE, +) +from NodeGraphQt.qgraphics.node_abstract import AbstractNodeItem +from NodeGraphQt.qgraphics.node_backdrop import BackdropNodeItem +from NodeGraphQt.qgraphics.pipe import PipeItem, LivePipeItem +from NodeGraphQt.qgraphics.port import PortItem +from NodeGraphQt.qgraphics.slicer import SlicerPipeItem +from NodeGraphQt.widgets.dialogs import BaseDialog, FileDialog +from NodeGraphQt.widgets.scene import NodeScene +from NodeGraphQt.widgets.tab_search import TabSearchMenuWidget + +ZOOM_MIN = -0.95 +ZOOM_MAX = 2.0 + + +class NodeViewer(QtWidgets.QGraphicsView): + """ + The widget interface used for displaying the scene and nodes. + + functions in this class should mainly be called by the + class:`NodeGraphQt.NodeGraph` class. + """ + + # node viewer signals. + # (some of these signals are called by port & node items and connected + # to the node graph slot functions) + moved_nodes = QtCore.Signal(object) + search_triggered = QtCore.Signal(str, tuple) + connection_sliced = QtCore.Signal(list) + connection_changed = QtCore.Signal(list, list) + insert_node = QtCore.Signal(object, str, object) + node_name_changed = QtCore.Signal(str, str) + node_backdrop_updated = QtCore.Signal(str, str, object) + + # pass through signals that are translated into "NodeGraph()" signals. + node_selected = QtCore.Signal(str) + node_selection_changed = QtCore.Signal(list, list) + node_double_clicked = QtCore.Signal(str) + data_dropped = QtCore.Signal(QtCore.QMimeData, object) + context_menu_prompt = QtCore.Signal(str, object) + + def __init__(self, parent=None, undo_stack=None): + """ + Args: + parent: + undo_stack (QtGui.QUndoStack): undo stack from the parent + graph controller. + """ + super(NodeViewer, self).__init__(parent) + + self.setScene(NodeScene(self)) + self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setViewportUpdateMode(QtWidgets.QGraphicsView.ViewportUpdateMode.FullViewportUpdate) + self.setCacheMode(QtWidgets.QGraphicsView.CacheModeFlag.CacheBackground) + self.setOptimizationFlag( + QtWidgets.QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing) + + self.setAcceptDrops(True) + self.resize(850, 800) + + self._scene_range = QtCore.QRectF( + 0, 0, self.size().width(), self.size().height()) + self._update_scene() + self._last_size = self.size() + + self._layout_direction = LayoutDirectionEnum.HORIZONTAL.value + + self._pipe_layout = PipeLayoutEnum.CURVED.value + self._detached_port = None + self._start_port = None + self._origin_pos = None + self._previous_pos = QtCore.QPoint(int(self.width() / 2), + int(self.height() / 2)) + self._prev_selection_nodes = [] + self._prev_selection_pipes = [] + self._node_positions = {} + + self._rubber_band = QtWidgets.QRubberBand( + QtWidgets.QRubberBand.Shape.Rectangle, self + ) + self._rubber_band.isActive = False + + text_color = QtGui.QColor(*tuple(map( + lambda i, j: i - j, (255, 255, 255), + ViewerEnum.BACKGROUND_COLOR.value + ))) + text_color.setAlpha(50) + self._cursor_text = QtWidgets.QGraphicsTextItem() + self._cursor_text.setFlag(self._cursor_text.GraphicsItemFlag.ItemIsSelectable, False) + self._cursor_text.setDefaultTextColor(text_color) + self._cursor_text.setZValue(Z_VAL_PIPE - 1) + font = self._cursor_text.font() + font.setPointSize(7) + self._cursor_text.setFont(font) + self.scene().addItem(self._cursor_text) + + self._LIVE_PIPE = LivePipeItem() + self._LIVE_PIPE.setVisible(False) + self.scene().addItem(self._LIVE_PIPE) + + self._SLICER_PIPE = SlicerPipeItem() + self._SLICER_PIPE.setVisible(False) + self.scene().addItem(self._SLICER_PIPE) + + self._search_widget = TabSearchMenuWidget() + self._search_widget.search_submitted.connect(self._on_search_submitted) + + # workaround fix for shortcuts from the non-native menu. + # actions don't seem to trigger so we create a hidden menu bar. + self._ctx_menu_bar = QtWidgets.QMenuBar(self) + self._ctx_menu_bar.setNativeMenuBar(False) + # shortcuts don't work with "setVisibility(False)". + self._ctx_menu_bar.setMaximumSize(0, 0) + + # context menus. + self._ctx_graph_menu = BaseMenu('NodeGraph', self) + self._ctx_node_menu = BaseMenu('Nodes', self) + + if undo_stack: + self._undo_action = undo_stack.createUndoAction(self, '&Undo') + self._redo_action = undo_stack.createRedoAction(self, '&Redo') + else: + self._undo_action = None + self._redo_action = None + + self._build_context_menus() + + self.acyclic = True + self.pipe_collision = False + self.pipe_slicing = True + + self.LMB_state = False + self.RMB_state = False + self.MMB_state = False + self.ALT_state = False + self.CTRL_state = False + self.SHIFT_state = False + self.COLLIDING_state = False + + # connection constrains. + # TODO: maybe this should be a reference to the graph model instead? + self.accept_connection_types = None + self.reject_connection_types = None + + def __repr__(self): + return '<{}() object at {}>'.format( + self.__class__.__name__, hex(id(self))) + + def focusInEvent(self, event): + """ + Args: + event (QtGui.QFocusEvent): focus event. + """ + # workaround fix: Re-populate the QMenuBar so the QAction shotcuts don't + # conflict with parent existing host app. + self._ctx_menu_bar.addMenu(self._ctx_graph_menu) + self._ctx_menu_bar.addMenu(self._ctx_node_menu) + return super(NodeViewer, self).focusInEvent(event) + + def focusOutEvent(self, event): + """ + Args: + event (QtGui.QFocusEvent): focus event. + """ + # workaround fix: Clear the QMenuBar so the QAction shotcuts don't + # conflict with existing parent host app. + self._ctx_menu_bar.clear() + return super(NodeViewer, self).focusOutEvent(event) + + # --- private --- + + def _build_context_menus(self): + """ + Build context menu for the node graph. + """ + # "node context menu" disabled by default and enabled when a action + # is added through the "NodesMenu" interface. + self._ctx_node_menu.setDisabled(True) + + # add the base menus. + self._ctx_menu_bar.addMenu(self._ctx_graph_menu) + self._ctx_menu_bar.addMenu(self._ctx_node_menu) + + # setup the undo and redo actions. + if self._undo_action and self._redo_action: + self._undo_action.setShortcuts(QtGui.QKeySequence.StandardKey.Undo) + self._redo_action.setShortcuts(QtGui.QKeySequence.StandardKey.Redo) + if LooseVersion(QtCore.qVersion()) >= LooseVersion('5.10'): + self._undo_action.setShortcutVisibleInContextMenu(True) + self._redo_action.setShortcutVisibleInContextMenu(True) + + # undo & redo always at the top of the "node graph context menu". + self._ctx_graph_menu.addAction(self._undo_action) + self._ctx_graph_menu.addAction(self._redo_action) + self._ctx_graph_menu.addSeparator() + + def _set_viewer_zoom(self, value, sensitivity=None, pos=None): + """ + Sets the zoom level. + + Args: + value (float): zoom factor. + sensitivity (float): zoom sensitivity. + pos (QtCore.QPoint): mapped position. + """ + if pos: + pos = self.mapToScene(pos) + if sensitivity is None: + scale = 1.001 ** value + self.scale(scale, scale, pos) + return + + if value == 0.0: + return + + scale = (0.9 + sensitivity) if value < 0.0 else (1.1 - sensitivity) + zoom = self.get_zoom() + if ZOOM_MIN >= zoom: + if scale == 0.9: + return + if ZOOM_MAX <= zoom: + if scale == 1.1: + return + self.scale(scale, scale, pos) + + def _set_viewer_pan(self, pos_x, pos_y): + """ + Set the viewer in panning mode. + + Args: + pos_x (float): x pos. + pos_y (float): y pos. + """ + self._scene_range.adjust(pos_x, pos_y, pos_x, pos_y) + self._update_scene() + + def scale(self, sx, sy, pos=None): + scale = [sx, sx] + center = pos or self._scene_range.center() + w = self._scene_range.width() / scale[0] + h = self._scene_range.height() / scale[1] + self._scene_range = QtCore.QRectF( + center.x() - (center.x() - self._scene_range.left()) / scale[0], + center.y() - (center.y() - self._scene_range.top()) / scale[1], + w, h + ) + self._update_scene() + + def _update_scene(self): + """ + Redraw the scene. + """ + self.setSceneRect(self._scene_range) + self.fitInView(self._scene_range, QtCore.Qt.AspectRatioMode.KeepAspectRatio) + + def _combined_rect(self, nodes): + """ + Returns a QRectF with the combined size of the provided node items. + + Args: + nodes (list[AbstractNodeItem]): list of node qgraphics items. + + Returns: + QtCore.QRectF: combined rect + """ + group = self.scene().createItemGroup(nodes) + rect = group.boundingRect() + self.scene().destroyItemGroup(group) + return rect + + def _items_near(self, pos, item_type=None, width=20, height=20): + """ + Filter node graph items from the specified position, width and + height area. + + Args: + pos (QtCore.QPoint): scene pos. + item_type: filter item type. (optional) + width (int): width area. + height (int): height area. + + Returns: + list: qgraphics items from the scene. + """ + x, y = pos.x() - width, pos.y() - height + rect = QtCore.QRectF(x, y, width, height) + items = [] + excl = [self._LIVE_PIPE, self._SLICER_PIPE] + for item in self.scene().items(rect): + if item in excl: + continue + if not item_type or isinstance(item, item_type): + items.append(item) + return items + + def _on_search_submitted(self, node_type): + """ + Slot function triggered when the ``TabSearchMenuWidget`` has + submitted a search. + + This will emit the "search_triggered" signal and tell the parent node + graph to create a new node object. + + Args: + node_type (str): node type identifier. + """ + pos = self.mapToScene(self._previous_pos) + self.search_triggered.emit(node_type, (pos.x(), pos.y())) + + def _on_pipes_sliced(self, path): + """ + Triggered when the slicer pipe is active + + Args: + path (QtGui.QPainterPath): slicer path. + """ + ports = [] + for i in self.scene().items(path): + if isinstance(i, PipeItem) and i != self._LIVE_PIPE: + if any([i.input_port.locked, i.output_port.locked]): + continue + ports.append([i.input_port, i.output_port]) + self.connection_sliced.emit(ports) + + # --- reimplemented events --- + + def resizeEvent(self, event): + w, h = self.size().width(), self.size().height() + if 0 in [w, h]: + self.resize(self._last_size) + delta = max(w / self._last_size.width(), h / self._last_size.height()) + self._set_viewer_zoom(delta) + self._last_size = self.size() + super(NodeViewer, self).resizeEvent(event) + + def contextMenuEvent(self, event): + self.RMB_state = False + + ctx_menu = None + ctx_menus = self.context_menus() + + prompted_data = None, None + + if ctx_menus['nodes'].isEnabled(): + pos = self.mapToScene(self._previous_pos) + items = self._items_near(pos) + nodes = [i for i in items if isinstance(i, AbstractNodeItem)] + if nodes: + node = nodes[0] + ctx_menu = ctx_menus['nodes'].get_menu(node.type_, node.id) + if ctx_menu: + for action in ctx_menu.actions(): + if not action.menu(): + action.node_id = node.id + prompted_data = 'nodes', node.id + + if not ctx_menu: + ctx_menu = ctx_menus['graph'] + prompted_data = 'graph', None + + if len(ctx_menu.actions()) > 0: + if ctx_menu.isEnabled(): + self.context_menu_prompt.emit( + prompted_data[0], prompted_data[1] + ) + ctx_menu.exec_(event.globalPos()) + else: + return super(NodeViewer, self).contextMenuEvent(event) + + return super(NodeViewer, self).contextMenuEvent(event) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.LMB_state = True + elif event.button() == QtCore.Qt.MouseButton.RightButton: + self.RMB_state = True + elif event.button() == QtCore.Qt.MouseButton.MiddleButton: + self.MMB_state = True + + self._origin_pos = event.pos() + self._previous_pos = event.pos() + (self._prev_selection_nodes, + self._prev_selection_pipes) = self.selected_items() + + # close tab search + if self._search_widget.isVisible(): + self.tab_search_toggle() + + # cursor pos. + map_pos = self.mapToScene(event.pos()) + + # pipe slicer enabled. + if self.pipe_slicing: + slicer_mode = all([ + self.ALT_state, self.SHIFT_state, self.LMB_state + ]) + if slicer_mode: + self._SLICER_PIPE.draw_path(map_pos, map_pos) + self._SLICER_PIPE.setVisible(True) + return + + # pan mode. + if self.ALT_state: + return + + items = self._items_near(map_pos, None, 20, 20) + pipes = [] + nodes = [] + backdrop = None + for itm in items: + if isinstance(itm, PipeItem): + pipes.append(itm) + elif isinstance(itm, AbstractNodeItem): + if isinstance(itm, BackdropNodeItem): + backdrop = itm + continue + nodes.append(itm) + + if nodes: + self.MMB_state = False + + # record the node selection as "self.selected_nodes()" is not updated + # here on the mouse press event. + selection = set([]) + + if self.LMB_state: + # toggle extend node selection. + if self.SHIFT_state: + if items and backdrop == items[0]: + backdrop.selected = not backdrop.selected + if backdrop.selected: + selection.add(backdrop) + for n in backdrop.get_nodes(): + n.selected = backdrop.selected + if backdrop.selected: + selection.add(n) + else: + for node in nodes: + node.selected = not node.selected + if node.selected: + selection.add(node) + # unselected nodes with the "ctrl" key. + elif self.CTRL_state: + if items and backdrop == items[0]: + backdrop.selected = False + else: + for node in nodes: + node.selected = False + # if no modifier keys then add to selection set. + else: + if backdrop: + selection.add(backdrop) + for n in backdrop.get_nodes(): + selection.add(n) + for node in nodes: + if node.selected: + selection.add(node) + + selection.update(self.selected_nodes()) + + # update the recorded node positions. + self._node_positions.update({n: n.xy_pos for n in selection}) + + # show selection marquee. + if self.LMB_state and not items: + rect = QtCore.QRect(self._previous_pos, QtCore.QSize()) + rect = rect.normalized() + map_rect = self.mapToScene(rect).boundingRect() + self.scene().update(map_rect) + self._rubber_band.setGeometry(rect) + self._rubber_band.isActive = True + + # stop here so we don't select a node. + # (ctrl modifier can be used for something else in future.) + if self.CTRL_state: + return + + # allow new live pipe with the shift modifier on port that allow + # for multi connection. + if self.SHIFT_state: + if pipes: + pipes[0].reset() + port = pipes[0].port_from_pos(map_pos, reverse=True) + if not port.locked and port.multi_connection: + self._cursor_text.setPlainText('') + self._cursor_text.setVisible(False) + self.start_live_connection(port) + + # return here as the default behaviour unselects nodes with + # the shift modifier. + return + + if not self._LIVE_PIPE.isVisible(): + super(NodeViewer, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.LMB_state = False + elif event.button() == QtCore.Qt.MouseButton.RightButton: + self.RMB_state = False + elif event.button() == QtCore.Qt.MouseButton.MiddleButton: + self.MMB_state = False + + # hide pipe slicer. + if self._SLICER_PIPE.isVisible(): + self._on_pipes_sliced(self._SLICER_PIPE.path()) + p = QtCore.QPointF(0.0, 0.0) + self._SLICER_PIPE.draw_path(p, p) + self._SLICER_PIPE.setVisible(False) + + # hide selection marquee + if self._rubber_band.isActive: + self._rubber_band.isActive = False + if self._rubber_band.isVisible(): + rect = self._rubber_band.rect() + map_rect = self.mapToScene(rect).boundingRect() + self._rubber_band.hide() + + rect = QtCore.QRect(self._origin_pos, event.pos()).normalized() + rect_items = self.scene().items( + self.mapToScene(rect).boundingRect() + ) + node_ids = [] + for item in rect_items: + if isinstance(item, AbstractNodeItem): + node_ids.append(item.id) + + # emit the node selection signals. + if node_ids: + prev_ids = [ + n.id for n in self._prev_selection_nodes + if not n.selected + ] + self.node_selected.emit(node_ids[0]) + self.node_selection_changed.emit(node_ids, prev_ids) + + self.scene().update(map_rect) + return + + # find position changed nodes and emit signal. + moved_nodes = { + n: xy_pos for n, xy_pos in self._node_positions.items() + if n.xy_pos != xy_pos + } + # only emit of node is not colliding with a pipe. + if moved_nodes and not self.COLLIDING_state: + self.moved_nodes.emit(moved_nodes) + + # reset recorded positions. + self._node_positions = {} + + # emit signal if selected node collides with pipe. + # Note: if collide state is true then only 1 node is selected. + nodes, pipes = self.selected_items() + if self.COLLIDING_state and nodes and pipes: + self.insert_node.emit(pipes[0], nodes[0].id, moved_nodes) + + # emit node selection changed signal. + prev_ids = [n.id for n in self._prev_selection_nodes if not n.selected] + node_ids = [n.id for n in nodes if n not in self._prev_selection_nodes] + self.node_selection_changed.emit(node_ids, prev_ids) + + super(NodeViewer, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + if self.ALT_state and self.SHIFT_state: + if self.pipe_slicing: + if self.LMB_state and self._SLICER_PIPE.isVisible(): + p1 = self._SLICER_PIPE.path().pointAtPercent(0) + p2 = self.mapToScene(self._previous_pos) + self._SLICER_PIPE.draw_path(p1, p2) + self._SLICER_PIPE.show() + self._previous_pos = event.pos() + super(NodeViewer, self).mouseMoveEvent(event) + return + + if self.MMB_state and self.ALT_state: + pos_x = (event.x() - self._previous_pos.x()) + zoom = 0.1 if pos_x > 0 else -0.1 + self._set_viewer_zoom(zoom, 0.05, pos=event.pos()) + elif self.MMB_state or (self.LMB_state and self.ALT_state): + previous_pos = self.mapToScene(self._previous_pos) + current_pos = self.mapToScene(event.pos()) + delta = previous_pos - current_pos + self._set_viewer_pan(delta.x(), delta.y()) + + if not self.ALT_state: + if self.SHIFT_state or self.CTRL_state: + if not self._LIVE_PIPE.isVisible(): + self._cursor_text.setPos(self.mapToScene(event.pos())) + + if self.LMB_state and self._rubber_band.isActive: + rect = QtCore.QRect(self._origin_pos, event.pos()).normalized() + # if the rubber band is too small, do not show it. + if max(rect.width(), rect.height()) > 5: + if not self._rubber_band.isVisible(): + self._rubber_band.show() + map_rect = self.mapToScene(rect).boundingRect() + path = QtGui.QPainterPath() + path.addRect(map_rect) + self._rubber_band.setGeometry(rect) + self.scene().setSelectionArea( + path, mode=QtCore.Qt.ItemSelectionMode.IntersectsItemShape + ) + self.scene().update(map_rect) + + if self.SHIFT_state or self.CTRL_state: + nodes, pipes = self.selected_items() + + for node in self._prev_selection_nodes: + node.selected = True + + if self.CTRL_state: + for pipe in pipes: + pipe.setSelected(False) + for node in nodes: + node.selected = False + + elif self.LMB_state: + self.COLLIDING_state = False + nodes, pipes = self.selected_items() + if len(nodes) == 1: + node = nodes[0] + [p.setSelected(False) for p in pipes] + + if self.pipe_collision: + colliding_pipes = [ + i for i in node.collidingItems() + if isinstance(i, PipeItem) and i.isVisible() + ] + for pipe in colliding_pipes: + if not pipe.input_port: + continue + port_node_check = all([ + not pipe.input_port.node is node, + not pipe.output_port.node is node + ]) + if port_node_check: + pipe.setSelected(True) + self.COLLIDING_state = True + break + + self._previous_pos = event.pos() + super(NodeViewer, self).mouseMoveEvent(event) + + def wheelEvent(self, event): + try: + delta = event.delta() + except AttributeError: + # For PyQt5 + delta = event.angleDelta().y() + if delta == 0: + delta = event.angleDelta().x() + self._set_viewer_zoom(delta, pos=event.pos()) + + def dropEvent(self, event): + pos = self.mapToScene(event.pos()) + event.setDropAction(QtCore.Qt.DropAction.CopyAction) + self.data_dropped.emit( + event.mimeData(), QtCore.QPointF(pos.x(), pos.y()) + ) + + def dragEnterEvent(self, event): + is_acceptable = any([ + event.mimeData().hasFormat(i) for i in + ['nodegraphqt/nodes', 'text/plain', 'text/uri-list'] + ]) + if is_acceptable: + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + is_acceptable = any([ + event.mimeData().hasFormat(i) for i in + ['nodegraphqt/nodes', 'text/plain', 'text/uri-list'] + ]) + if is_acceptable: + event.accept() + else: + event.ignore() + + def dragLeaveEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + """ + Key press event re-implemented to update the states for attributes: + - ALT_state + - CTRL_state + - SHIFT_state + + Args: + event (QtGui.QKeyEvent): key event. + """ + self.ALT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier + self.CTRL_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier + self.SHIFT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier + + # Todo: find a better solution to catch modifier keys. + if event.modifiers() == (QtCore.Qt.KeyboardModifier.AltModifier | QtCore.Qt.KeyboardModifier.ShiftModifier): + self.ALT_state = True + self.SHIFT_state = True + + if self._LIVE_PIPE.isVisible(): + super(NodeViewer, self).keyPressEvent(event) + return + + # show cursor text + overlay_text = None + self._cursor_text.setVisible(False) + if not self.ALT_state: + if self.SHIFT_state: + overlay_text = '\n SHIFT:\n Toggle/Extend Selection' + elif self.CTRL_state: + overlay_text = '\n CTRL:\n Deselect Nodes' + elif self.ALT_state and self.SHIFT_state: + if self.pipe_slicing: + overlay_text = '\n ALT + SHIFT:\n Pipe Slicer Enabled' + if overlay_text: + self._cursor_text.setPlainText(overlay_text) + self._cursor_text.setPos(self.mapToScene(self._previous_pos)) + self._cursor_text.setVisible(True) + + super(NodeViewer, self).keyPressEvent(event) + + def keyReleaseEvent(self, event): + """ + Key release event re-implemented to update the states for attributes: + - ALT_state + - CTRL_state + - SHIFT_state + + Args: + event (QtGui.QKeyEvent): key event. + """ + self.ALT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier + self.CTRL_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier + self.SHIFT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier + super(NodeViewer, self).keyReleaseEvent(event) + + # hide and reset cursor text. + self._cursor_text.setPlainText('') + self._cursor_text.setVisible(False) + + # --- scene events --- + + def sceneMouseMoveEvent(self, event): + """ + triggered mouse move event for the scene. + - redraw the live connection pipe. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): + The event handler from the QtWidgets.QGraphicsScene + """ + if not self._LIVE_PIPE.isVisible(): + return + if not self._start_port: + return + + pos = event.scenePos() + pointer_color = None + for item in self.scene().items(pos): + if not isinstance(item, PortItem): + continue + + x = item.boundingRect().width() / 2 + y = item.boundingRect().height() / 2 + pos = item.scenePos() + pos.setX(pos.x() + x) + pos.setY(pos.y() + y) + if item == self._start_port: + break + pointer_color = PipeEnum.HIGHLIGHT_COLOR.value + accept = self._validate_accept_connection(self._start_port, item) + if not accept: + pointer_color = [150, 60, 255] + break + reject = self._validate_reject_connection(self._start_port, item) + if reject: + pointer_color = [150, 60, 255] + break + + if self.acyclic: + if item.node == self._start_port.node: + pointer_color = PipeEnum.DISABLED_COLOR.value + elif item.port_type == self._start_port.port_type: + pointer_color = PipeEnum.DISABLED_COLOR.value + break + + self._LIVE_PIPE.draw_path( + self._start_port, cursor_pos=pos, color=pointer_color + ) + + def sceneMousePressEvent(self, event): + """ + triggered mouse press event for the scene (takes priority over viewer event). + - detect selected pipe and start connection. + - remap Shift and Ctrl modifier. + + Args: + event (QtWidgets.QGraphicsScenePressEvent): + The event handler from the QtWidgets.QGraphicsScene + """ + # pipe slicer enabled. + if self.ALT_state and self.SHIFT_state: + return + + # viewer pan mode. + if self.ALT_state: + return + + if self._LIVE_PIPE.isVisible(): + self.apply_live_connection(event) + return + + pos = event.scenePos() + items = self._items_near(pos, None, 5, 5) + + # filter from the selection stack in the following order + # "node, port, pipe" this is to avoid selecting items under items. + node, port, pipe = None, None, None + for item in items: + if isinstance(item, AbstractNodeItem): + node = item + elif isinstance(item, PortItem): + port = item + elif isinstance(item, PipeItem): + pipe = item + if any([node, port, pipe]): + break + + if port: + if port.locked: + return + + if not port.multi_connection and port.connected_ports: + self._detached_port = port.connected_ports[0] + self.start_live_connection(port) + if not port.multi_connection: + [p.delete() for p in port.connected_pipes] + return + + if node: + node_items = self._items_near(pos, AbstractNodeItem, 3, 3) + + # record the node positions at selection time. + for n in node_items: + self._node_positions[n] = n.xy_pos + + # emit selected node id with LMB. + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.node_selected.emit(node.id) + + if not isinstance(node, BackdropNodeItem): + return + + if pipe: + if not self.LMB_state: + return + + from_port = pipe.port_from_pos(pos, True) + + if from_port.locked: + return + + from_port.hovered = True + + attr = { + PortTypeEnum.IN.value: 'output_port', + PortTypeEnum.OUT.value: 'input_port' + } + self._detached_port = getattr(pipe, attr[from_port.port_type]) + self.start_live_connection(from_port) + self._LIVE_PIPE.draw_path(self._start_port, cursor_pos=pos) + + if self.SHIFT_state: + self._LIVE_PIPE.shift_selected = True + return + + pipe.delete() + + def sceneMouseReleaseEvent(self, event): + """ + triggered mouse release event for the scene. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): + The event handler from the QtWidgets.QGraphicsScene + """ + if event.button() != QtCore.Qt.MouseButton.MiddleButton: + self.apply_live_connection(event) + + # --- port connections --- + + def _validate_accept_connection(self, from_port, to_port): + """ + Check if a pipe connection is allowed if there are a constraints set + on the ports. + + Args: + from_port (PortItem): + to_port (PortItem): + + Returns: + bool: true to allow connection. + """ + accept_validation = [] + + to_ptype = to_port.port_type + from_ptype = from_port.port_type + + # validate the start. + from_data = self.accept_connection_types.get(from_port.node.type_) or {} + constraints = from_data.get(from_ptype, {}).get(from_port.name, {}) + accept_data = constraints.get(to_port.node.type_, {}) + accepted_pnames = accept_data.get(to_ptype, {}) + if constraints: + if to_port.name in accepted_pnames: + accept_validation.append(True) + else: + accept_validation.append(False) + + # validate the end. + to_data = self.accept_connection_types.get(to_port.node.type_) or {} + constraints = to_data.get(to_ptype, {}).get(to_port.name, {}) + accept_data = constraints.get(from_port.node.type_, {}) + accepted_pnames = accept_data.get(from_ptype, {}) + if constraints: + if from_port.name in accepted_pnames: + accept_validation.append(True) + else: + accept_validation.append(False) + + if False in accept_validation: + return False + return True + + def _validate_reject_connection(self, from_port, to_port): + """ + Check if a pipe connection is NOT allowed if there are a constrains set + on the ports. + + Args: + from_port (PortItem): + to_port (PortItem): + + Returns: + bool: true to reject connection. + """ + to_ptype = to_port.port_type + from_ptype = from_port.port_type + + to_data = self.reject_connection_types.get(to_port.node.type_) or {} + constraints = to_data.get(to_ptype, {}).get(to_port.name, {}) + reject_data = constraints.get(from_port.node.type_, {}) + + rejected_pnames = reject_data.get(from_ptype) + if rejected_pnames: + if from_port.name in rejected_pnames: + return True + return False + + from_data = self.reject_connection_types.get(from_port.node.type_) or {} + constraints = from_data.get(from_ptype, {}).get(from_port.name, {}) + reject_data = constraints.get(to_port.node.type_, {}) + + rejected_pnames = reject_data.get(to_ptype) + if rejected_pnames: + if to_port.name in rejected_pnames: + return True + return False + return False + + def apply_live_connection(self, event): + """ + triggered mouse press/release event for the scene. + - verifies the live connection pipe. + - makes a connection pipe if valid. + - emits the "connection changed" signal. + + Args: + event (QtWidgets.QGraphicsSceneMouseEvent): + The event handler from the QtWidgets.QGraphicsScene + """ + if not self._LIVE_PIPE.isVisible(): + return + + self._start_port.hovered = False + + # find the end port. + end_port = None + for item in self.scene().items(event.scenePos()): + if isinstance(item, PortItem): + end_port = item + break + + connected = [] + disconnected = [] + + # if port disconnected from existing pipe. + if end_port is None: + if self._detached_port and not self._LIVE_PIPE.shift_selected: + dist = math.hypot(self._previous_pos.x() - self._origin_pos.x(), + self._previous_pos.y() - self._origin_pos.y()) + if dist <= 2.0: # cursor pos threshold. + self.establish_connection(self._start_port, + self._detached_port) + self._detached_port = None + else: + disconnected.append((self._start_port, self._detached_port)) + self.connection_changed.emit(disconnected, connected) + + self._detached_port = None + self.end_live_connection() + return + + else: + if self._start_port is end_port: + return + + # if connection to itself + same_node_connection = end_port.node == self._start_port.node + if not self.acyclic: + # allow a node cycle connection. + same_node_connection = False + + # constrain check + accept_connection = self._validate_accept_connection( + self._start_port, end_port + ) + reject_connection = self._validate_reject_connection( + self._start_port, end_port + ) + + # restore connection check. + restore_connection = any([ + # if the end port is locked. + end_port.locked, + # if same port type. + end_port.port_type == self._start_port.port_type, + # if connection to itself. + same_node_connection, + # if end port is the start port. + end_port == self._start_port, + # if detached port is the end port. + self._detached_port == end_port, + # if a port has a accept port type constrain. + not accept_connection, + # if a port has a reject port type constrain. + reject_connection + ]) + if restore_connection: + if self._detached_port: + to_port = self._detached_port or end_port + self.establish_connection(self._start_port, to_port) + self._detached_port = None + self.end_live_connection() + return + + # end connection if starting port is already connected. + if self._start_port.multi_connection and \ + self._start_port in end_port.connected_ports: + self._detached_port = None + self.end_live_connection() + return + + # register as disconnected if not acyclic. + if self.acyclic and not self.acyclic_check(self._start_port, end_port): + if self._detached_port: + disconnected.append((self._start_port, self._detached_port)) + + self.connection_changed.emit(disconnected, connected) + + self._detached_port = None + self.end_live_connection() + return + + # make connection. + if not end_port.multi_connection and end_port.connected_ports: + dettached_end = end_port.connected_ports[0] + disconnected.append((end_port, dettached_end)) + + if self._detached_port: + disconnected.append((self._start_port, self._detached_port)) + + connected.append((self._start_port, end_port)) + + self.connection_changed.emit(disconnected, connected) + + self._detached_port = None + self.end_live_connection() + + def start_live_connection(self, selected_port): + """ + create new pipe for the connection. + (show the live pipe visibility from the port following the cursor position) + """ + if not selected_port: + return + self._start_port = selected_port + if self._start_port.type == PortTypeEnum.IN.value: + self._LIVE_PIPE.input_port = self._start_port + elif self._start_port == PortTypeEnum.OUT.value: + self._LIVE_PIPE.output_port = self._start_port + self._LIVE_PIPE.setVisible(True) + self._LIVE_PIPE.draw_index_pointer( + selected_port, + self.mapToScene(self._origin_pos) + ) + + def end_live_connection(self): + """ + delete live connection pipe and reset start port. + (hides the pipe item used for drawing the live connection) + """ + self._LIVE_PIPE.reset_path() + self._LIVE_PIPE.setVisible(False) + self._LIVE_PIPE.shift_selected = False + self._start_port = None + + def establish_connection(self, start_port, end_port): + """ + establish a new pipe connection. + (adds a new pipe item to draw between 2 ports) + """ + pipe = PipeItem() + self.scene().addItem(pipe) + pipe.set_connections(start_port, end_port) + pipe.draw_path(pipe.input_port, pipe.output_port) + if start_port.node.selected or end_port.node.selected: + pipe.highlight() + if not start_port.node.visible or not end_port.node.visible: + pipe.hide() + + @staticmethod + def acyclic_check(start_port, end_port): + """ + Validate the node connections, so it doesn't loop itself. + + Args: + start_port (PortItem): port item. + end_port (PortItem): port item. + + Returns: + bool: True if port connection is valid. + """ + start_node = start_port.node + check_nodes = [end_port.node] + io_types = { + PortTypeEnum.IN.value: 'outputs', + PortTypeEnum.OUT.value: 'inputs' + } + while check_nodes: + check_node = check_nodes.pop(0) + for check_port in getattr(check_node, io_types[end_port.port_type]): + if check_port.connected_ports: + for port in check_port.connected_ports: + if port.node != start_node: + check_nodes.append(port.node) + else: + return False + return True + + # --- viewer --- + + def tab_search_set_nodes(self, nodes): + self._search_widget.set_nodes(nodes) + + def tab_search_toggle(self): + state = self._search_widget.isVisible() + if not state: + self._search_widget.setVisible(state) + self.setFocus() + return + + pos = self._previous_pos + rect = self._search_widget.rect() + new_pos = QtCore.QPoint(int(pos.x() - rect.width() / 2), + int(pos.y() - rect.height() / 2)) + self._search_widget.move(new_pos) + self._search_widget.setVisible(state) + self._search_widget.setFocus() + + rect = self.mapToScene(rect).boundingRect() + self.scene().update(rect) + + def rebuild_tab_search(self): + if isinstance(self._search_widget, TabSearchMenuWidget): + self._search_widget.rebuild = True + + def qaction_for_undo(self): + """ + Get the undo QAction from the parent undo stack. + + Returns: + QtWidgets.QAction: undo action. + """ + return self._undo_action + + def qaction_for_redo(self): + """ + Get the redo QAction from the parent undo stack. + + Returns: + QtWidgets.QAction: redo action. + """ + return self._redo_action + + def context_menus(self): + """ + All the available context menus for the viewer. + + Returns: + dict: viewer context menu. + """ + return {'graph': self._ctx_graph_menu, 'nodes': self._ctx_node_menu} + + def question_dialog(self, text, title='Node Graph', dialog_icon=None, + custom_icon=None, parent=None): + """ + Prompt node viewer question dialog widget with "yes", "no" buttons. + + Args: + text (str): dialog text. + title (str): dialog window title. + dialog_icon (str): display icon. ("information", "warning", "critical") + custom_icon (str): custom icon to display. + parent (QtWidgets.QObject): override dialog parent. (optional) + + Returns: + bool: true if user click yes. + """ + parent = parent or self + + self.clear_key_state() + return BaseDialog.question_dialog( + parent, text, title, dialog_icon, custom_icon + ) + + def message_dialog(self, text, title='Node Graph', dialog_icon=None, + custom_icon=None, parent=None): + """ + Prompt node viewer message dialog widget with "ok" button. + + Args: + text (str): dialog text. + title (str): dialog window title. + dialog_icon (str): display icon. ("information", "warning", "critical") + custom_icon (str): custom icon to display. + parent (QtWidgets.QObject): override dialog parent. (optional) + """ + parent = parent or self + + self.clear_key_state() + BaseDialog.message_dialog(parent, text, title, dialog_icon, custom_icon) + + def load_dialog(self, current_dir=None, ext=None, parent=None): + """ + Prompt node viewer file load dialog widget. + + Args: + current_dir (str): directory path starting point. (optional) + ext (str): custom file extension filter type. (optional) + parent (QtWidgets.QObject): override dialog parent. (optional) + + Returns: + str: selected file path. + """ + parent = parent or self + + self.clear_key_state() + ext = '*{} '.format(ext) if ext else '' + ext_filter = ';;'.join([ + 'Node Graph ({}*json)'.format(ext), 'All Files (*)' + ]) + file_dlg = FileDialog.getOpenFileName( + parent, 'Open File', current_dir, ext_filter) + file = file_dlg[0] or None + return file + + def save_dialog(self, current_dir=None, ext=None, parent=None): + """ + Prompt node viewer file save dialog widget. + + Args: + current_dir (str): directory path starting point. (optional) + ext (str): custom file extension filter type. (optional) + parent (QtWidgets.QObject): override dialog parent. (optional) + + Returns: + str: selected file path. + """ + parent = parent or self + + self.clear_key_state() + ext_label = '*{} '.format(ext) if ext else '' + ext_type = '.{}'.format(ext) if ext else '.json' + ext_map = {'Node Graph ({}*json)'.format(ext_label): ext_type, + 'All Files (*)': ''} + file_dlg = FileDialog.getSaveFileName( + parent, 'Save Session', current_dir, ';;'.join(ext_map.keys())) + file_path = file_dlg[0] + if not file_path: + return + ext = ext_map[file_dlg[1]] + if ext and not file_path.endswith(ext): + file_path += ext + + return file_path + + def all_pipes(self): + """ + Returns all pipe qgraphic items. + + Returns: + list[PipeItem]: instances of pipe items. + """ + excl = [self._LIVE_PIPE, self._SLICER_PIPE] + return [i for i in self.scene().items() + if isinstance(i, PipeItem) and i not in excl] + + def all_nodes(self): + """ + Returns all node qgraphic items. + + Returns: + list[AbstractNodeItem]: instances of node items. + """ + return [i for i in self.scene().items() + if isinstance(i, AbstractNodeItem)] + + def selected_nodes(self): + """ + Returns selected node qgraphic items. + + Returns: + list[AbstractNodeItem]: instances of node items. + """ + return [i for i in self.scene().selectedItems() + if isinstance(i, AbstractNodeItem)] + + def selected_pipes(self): + """ + Returns selected pipe qgraphic items. + + Returns: + list[Pipe]: pipe items. + """ + pipes = [i for i in self.scene().selectedItems() + if isinstance(i, PipeItem)] + return pipes + + def selected_items(self): + """ + Return selected graphic items in the scene. + + Returns: + tuple(list[AbstractNodeItem], list[Pipe]): + selected (node items, pipe items). + """ + nodes = [] + pipes = [] + for item in self.scene().selectedItems(): + if isinstance(item, AbstractNodeItem): + nodes.append(item) + elif isinstance(item, PipeItem): + pipes.append(item) + return nodes, pipes + + def add_node(self, node, pos=None): + """ + Add node item into the scene. + + Args: + node (AbstractNodeItem): node item instance. + pos (tuple or list): node scene position. + """ + pos = pos or (self._previous_pos.x(), self._previous_pos.y()) + node.pre_init(self, pos) + self.scene().addItem(node) + node.post_init(self, pos) + + @staticmethod + def remove_node(node): + """ + Remove node item from the scene. + + Args: + node (AbstractNodeItem): node item instance. + """ + if isinstance(node, AbstractNodeItem): + node.delete() + + def move_nodes(self, nodes, pos=None, offset=None): + """ + Globally move specified nodes. + + Args: + nodes (list[AbstractNodeItem]): node items. + pos (tuple or list): custom x, y position. + offset (tuple or list): x, y position offset. + """ + group = self.scene().createItemGroup(nodes) + group_rect = group.boundingRect() + if pos: + x, y = pos + else: + pos = self.mapToScene(self._previous_pos) + x = pos.x() - group_rect.center().x() + y = pos.y() - group_rect.center().y() + if offset: + x += offset[0] + y += offset[1] + group.setPos(x, y) + self.scene().destroyItemGroup(group) + + def get_pipes_from_nodes(self, nodes=None): + nodes = nodes or self.selected_nodes() + if not nodes: + return + pipes = [] + for node in nodes: + n_inputs = node.inputs if hasattr(node, 'inputs') else [] + n_outputs = node.outputs if hasattr(node, 'outputs') else [] + + for port in n_inputs: + for pipe in port.connected_pipes: + connected_node = pipe.output_port.node + if connected_node in nodes: + pipes.append(pipe) + for port in n_outputs: + for pipe in port.connected_pipes: + connected_node = pipe.input_port.node + if connected_node in nodes: + pipes.append(pipe) + return pipes + + def center_selection(self, nodes=None): + """ + Center on the given nodes or all nodes by default. + + Args: + nodes (list[AbstractNodeItem]): a list of node items. + """ + if not nodes: + if self.selected_nodes(): + nodes = self.selected_nodes() + elif self.all_nodes(): + nodes = self.all_nodes() + if not nodes: + return + + rect = self._combined_rect(nodes) + self._scene_range.translate(rect.center() - self._scene_range.center()) + self.setSceneRect(self._scene_range) + + def get_pipe_layout(self): + """ + Returns the pipe layout mode. + + Returns: + int: pipe layout mode. + """ + return self._pipe_layout + + def set_pipe_layout(self, layout): + """ + Sets the pipe layout mode and redraw all pipe items in the scene. + + Args: + layout (int): pipe layout mode. (see the constants module) + """ + self._pipe_layout = layout + for pipe in self.all_pipes(): + pipe.draw_path(pipe.input_port, pipe.output_port) + + def get_layout_direction(self): + """ + Returns the layout direction set on the node graph viewer + used by the pipe items for drawing. + + Returns: + int: graph layout mode. + """ + return self._layout_direction + + def set_layout_direction(self, direction): + """ + Sets the node graph viewer layout direction for re-drawing + the pipe items. + + Args: + direction (int): graph layout direction. + """ + self._layout_direction = direction + for pipe_item in self.all_pipes(): + pipe_item.draw_path(pipe_item.input_port, pipe_item.output_port) + + def reset_zoom(self, cent=None): + """ + Reset the viewer zoom level. + + Args: + cent (QtCore.QPoint): specified center. + """ + self._scene_range = QtCore.QRectF(0, 0, + self.size().width(), + self.size().height()) + if cent: + self._scene_range.translate(cent - self._scene_range.center()) + self._update_scene() + + def get_zoom(self): + """ + Returns the viewer zoom level. + + Returns: + float: zoom level. + """ + transform = self.transform() + cur_scale = (transform.m11(), transform.m22()) + return float('{:0.2f}'.format(cur_scale[0] - 1.0)) + + def set_zoom(self, value=0.0): + """ + Set the viewer zoom level. + + Args: + value (float): zoom level + """ + if value == 0.0: + self.reset_zoom() + return + zoom = self.get_zoom() + if zoom < 0.0: + if not (ZOOM_MIN <= zoom <= ZOOM_MAX): + return + else: + if not (ZOOM_MIN <= value <= ZOOM_MAX): + return + value = value - zoom + self._set_viewer_zoom(value, 0.0) + + def zoom_to_nodes(self, nodes): + self._scene_range = self._combined_rect(nodes) + self._update_scene() + + if self.get_zoom() > 0.1: + self.reset_zoom(self._scene_range.center()) + + def force_update(self): + """ + Redraw the current node graph scene. + """ + self._update_scene() + + def scene_rect(self): + """ + Returns the scene rect size. + + Returns: + list[float]: x, y, width, height + """ + return [self._scene_range.x(), self._scene_range.y(), + self._scene_range.width(), self._scene_range.height()] + + def set_scene_rect(self, rect): + """ + Sets the scene rect and redraws the scene. + + Args: + rect (list[float]): x, y, width, height + """ + self._scene_range = QtCore.QRectF(*rect) + self._update_scene() + + def scene_center(self): + """ + Get the center x,y pos from the scene. + + Returns: + list[float]: x, y position. + """ + cent = self._scene_range.center() + return [cent.x(), cent.y()] + + def scene_cursor_pos(self): + """ + Returns the cursor last position mapped to the scene. + + Returns: + QtCore.QPoint: cursor position. + """ + return self.mapToScene(self._previous_pos) + + def nodes_rect_center(self, nodes): + """ + Get the center x,y pos from the specified nodes. + + Args: + nodes (list[AbstractNodeItem]): list of node qgrphics items. + + Returns: + list[float]: x, y position. + """ + cent = self._combined_rect(nodes).center() + return [cent.x(), cent.y()] + + def clear_key_state(self): + """ + Resets the Ctrl, Shift, Alt modifiers key states. + """ + self.CTRL_state = False + self.SHIFT_state = False + self.ALT_state = False + + def use_OpenGL(self): + """ + Use QOpenGLWidget as the viewer. + """ + # use QOpenGLWidget instead of the deprecated QGLWidget to avoid + # problems with Wayland. + + # TODO: Review this part and make sure we do not break anything + # import qtpy + # if qtpy.PYSIDE2: + # from PySide2.QtWidgets import QOpenGLWidget + # elif qtpy.PYQT5: + # from PyQt5.QtWidgets import QOpenGLWidget + # elif qtpy.PYSIDE6: + # from PySide6.QtOpenGLWidgets import QOpenGLWidget + # elif qtpy.PYQT6: + # from PyQt6.QtOpenGLWidgets import QOpenGLWidget + + self.setViewport(QtWidgets.QOpenGLWidget()) diff --git a/cuegui/NodeGraphQt/widgets/viewer_nav.py b/cuegui/NodeGraphQt/widgets/viewer_nav.py new file mode 100644 index 000000000..23fe60c6f --- /dev/null +++ b/cuegui/NodeGraphQt/widgets/viewer_nav.py @@ -0,0 +1,198 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from NodeGraphQt.constants import NodeEnum, ViewerNavEnum + + +class NodeNavigationDelagate(QtWidgets.QStyledItemDelegate): + + def paint(self, painter, option, index): + """ + Args: + painter (QtGui.QPainter): + option (QtGui.QStyleOptionViewItem): + index (QtCore.QModelIndex): + """ + if index.column() != 0: + super(NodeNavigationDelagate, self).paint(painter, option, index) + return + + item = index.model().item(index.row(), index.column()) + + margin = 1.0, 1.0 + rect = QtCore.QRectF( + option.rect.x() + margin[0], + option.rect.y() + margin[1], + option.rect.width() - (margin[0] * 2), + option.rect.height() - (margin[1] * 2) + ) + + painter.save() + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + + # background. + bg_color = QtGui.QColor(*ViewerNavEnum.ITEM_COLOR.value) + itm_color = QtGui.QColor(80, 128, 123) + if option.state & QtWidgets.QStyle.StateFlag.State_Selected: + bg_color = bg_color.lighter(120) + itm_color = QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value) + + roundness = 2.0 + painter.setBrush(bg_color) + painter.drawRoundedRect(rect, roundness, roundness) + + if index.row() != 0: + txt_offset = 8.0 + m = 6.0 + x = rect.left() + 2.0 + m + y = rect.top() + m + 2 + h = rect.height() - (m * 2) - 2 + painter.setBrush(itm_color) + for i in range(4): + itm_rect = QtCore.QRectF(x, y, 1.3, h) + painter.drawRoundedRect(itm_rect, 1.0, 1.0) + x += 2.0 + y += 2 + h -= 4 + else: + txt_offset = 5.0 + x = rect.left() + 4.0 + size = 10.0 + for clr in [QtGui.QColor(0, 0, 0, 80), itm_color]: + itm_rect = QtCore.QRectF( + x, rect.center().y() - (size / 2), size, size) + painter.setBrush(clr) + painter.drawRoundedRect(itm_rect, 2.0, 2.0) + size -= 5.0 + x += 2.5 + + # text + # pen_color = option.palette.text().color() + pen_color = QtGui.QColor(*tuple(map( + lambda i, j: i - j, (255, 255, 255), bg_color.getRgb() + ))) + pen = QtGui.QPen(pen_color, 0.5) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + + font = painter.font() + font_metrics = QtGui.QFontMetrics(font) + item_text = item.text().replace(' ', '_') + if hasattr(font_metrics, 'horizontalAdvance'): + font_width = font_metrics.horizontalAdvance(item_text) + else: + font_width = font_metrics.width(item_text) + font_height = font_metrics.height() + text_rect = QtCore.QRectF( + rect.center().x() - (font_width / 2) + txt_offset, + rect.center().y() - (font_height / 2), + font_width, font_height + ) + painter.drawText(text_rect, item.text()) + painter.restore() + + +class NodeNavigationWidget(QtWidgets.QListView): + + navigation_changed = QtCore.Signal(str, list) + + def __init__(self, parent=None): + super(NodeNavigationWidget, self).__init__(parent) + self.setSelectionMode(self.SelectionMode.SingleSelection) + self.setResizeMode(self.ResizeMode.Adjust) + self.setViewMode(self.ViewMode.ListMode) + self.setFlow(self.Flow.LeftToRight) + self.setDragEnabled(False) + self.setMinimumHeight(20) + self.setMaximumHeight(36) + self.setSpacing(0) + + # self.viewport().setAutoFillBackground(False) + self.setStyleSheet( + 'QListView {{border: 0px;background-color: rgb({0},{1},{2});}}' + .format(*ViewerNavEnum.BACKGROUND_COLOR.value) + ) + + self.setItemDelegate(NodeNavigationDelagate(self)) + self.setModel(QtGui.QStandardItemModel()) + + def keyPressEvent(self, event): + event.ignore() + + def mouseReleaseEvent(self, event): + super(NodeNavigationWidget, self).mouseReleaseEvent(event) + if not self.selectedIndexes(): + return + index = self.selectedIndexes()[0] + rows = reversed(range(1, self.model().rowCount())) + if index.row() == 0: + rows = [r for r in rows if r > 0] + else: + rows = [r for r in rows if index.row() < r] + if not rows: + return + rm_node_ids = [self.model().item(r, 0).toolTip() for r in rows] + node_id = self.model().item(index.row(), 0).toolTip() + [self.model().removeRow(r) for r in rows] + self.navigation_changed.emit(node_id, rm_node_ids) + + def clear(self): + self.model().sourceMode().clear() + + def add_label_item(self, label, node_id): + item = QtGui.QStandardItem(label) + item.setToolTip(node_id) + metrics = QtGui.QFontMetrics(item.font()) + if hasattr(metrics, 'horizontalAdvance'): + width = metrics.horizontalAdvance(item.text()) + else: + width = metrics.width(item.text()) + width *= 1.5 + item.setSizeHint(QtCore.QSize(int(width), 20)) + self.model().appendRow(item) + self.selectionModel().setCurrentIndex( + self.model().indexFromItem(item), + QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect) + + def update_label_item(self, label, node_id): + rows = reversed(range(self.model().rowCount())) + for r in rows: + item = self.model().item(r, 0) + if item.toolTip() == node_id: + item.setText(label) + + def remove_label_item(self, node_id): + rows = reversed(range(1, self.model().rowCount())) + node_ids = [self.model().item(r, 0).toolTip() for r in rows] + if node_id not in node_ids: + return + index = node_ids.index(node_id) + if index == 0: + rows = [r for r in rows if r > 0] + else: + rows = [r for r in rows if index < r] + [self.model().removeRow(r) for r in rows] + + +if __name__ == '__main__': + import sys + + def on_nav_changed(selected_id, remove_ids): + print(selected_id, remove_ids) + + app = QtWidgets.QApplication(sys.argv) + + widget = NodeNavigationWidget() + widget.navigation_changed.connect(on_nav_changed) + + widget.add_label_item('Close Graph', 'root') + for i in range(1, 5): + widget.add_label_item( + 'group node {}'.format(i), + 'node_id{}'.format(i) + ) + widget.resize(600, 30) + widget.show() + + app.exec_() From a258bc035b327ef85ea4440ad65262fb9d02bd5a Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 00:18:07 +0200 Subject: [PATCH 09/26] Fix python linting --- cuegui/cuegui/nodegraph/nodes/layer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cuegui/cuegui/nodegraph/nodes/layer.py b/cuegui/cuegui/nodegraph/nodes/layer.py index 90bfc0817..e7080e869 100644 --- a/cuegui/cuegui/nodegraph/nodes/layer.py +++ b/cuegui/cuegui/nodegraph/nodes/layer.py @@ -19,8 +19,8 @@ from __future__ import division import os from qtpy import QtGui -import NodeGraphQt.qgraphics.node_base import opencue +import NodeGraphQt.qgraphics.node_base import cuegui.images from cuegui.Constants import RGB_FRAME_STATE from cuegui.nodegraph.nodes.base import CueBaseNode From effb801f7dddfbd13fe4a7b231f27b4149335e89 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 01:21:10 +0200 Subject: [PATCH 10/26] Make QUndoStack compatible with both PySide2 and PySide6 --- cuegui/NodeGraphQt/base/graph.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cuegui/NodeGraphQt/base/graph.py b/cuegui/NodeGraphQt/base/graph.py index 5f5090d3b..6b01c6b24 100644 --- a/cuegui/NodeGraphQt/base/graph.py +++ b/cuegui/NodeGraphQt/base/graph.py @@ -7,6 +7,12 @@ from qtpy import QtCore, QtWidgets, QtGui +import qtpy +if qtpy.API_NAME in ("PyQt5", "PySide2"): + from qtpy.QtWidgets import QUndoStack +elif qtpy.API_NAME in ("PyQt6", "PySide6"): + from qtpy.QtGui import QUndoStack + from NodeGraphQt.base.commands import (NodeAddedCmd, NodesRemovedCmd, NodeMovedCmd, @@ -150,7 +156,7 @@ def __init__(self, parent=None, **kwargs): kwargs.get('node_factory') or NodeFactory()) self._undo_view = None self._undo_stack = ( - kwargs.get('undo_stack') or QtGui.QUndoStack(self) + kwargs.get('undo_stack') or QUndoStack(self) ) self._widget = None self._sub_graphs = {} From 6fbf2d15276ed570bb1c31ec67c0e50a05595d89 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 02:09:18 +0200 Subject: [PATCH 11/26] Add simple test for JobGraphPlugin --- cuegui/tests/plugins/JobGraphPlugin_tests.py | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 cuegui/tests/plugins/JobGraphPlugin_tests.py diff --git a/cuegui/tests/plugins/JobGraphPlugin_tests.py b/cuegui/tests/plugins/JobGraphPlugin_tests.py new file mode 100644 index 000000000..4912825fe --- /dev/null +++ b/cuegui/tests/plugins/JobGraphPlugin_tests.py @@ -0,0 +1,64 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Tests for cuegui.plugins.MonitorJobGraphPlugin.""" + + +import unittest + +import mock + +import opencue.compiled_proto.job_pb2 +import opencue.compiled_proto.depend_pb2 +import opencue.wrappers.job +import opencue.wrappers.layer +import opencue.wrappers.depend + +import qtpy.QtCore +import qtpy.QtGui +import qtpy.QtTest +import qtpy.QtWidgets + +import cuegui.Main +import cuegui.plugins.MonitorJobGraphPlugin +import cuegui.JobMonitorGraph +import cuegui.Style +from .. import test_utils + + +@mock.patch('opencue.cuebot.Cuebot.getStub', new=mock.Mock()) +class MonitorJobGraphPluginTests(unittest.TestCase): + + @mock.patch('opencue.cuebot.Cuebot.getStub', new=mock.Mock()) + @mock.patch('opencue.api.getJob') + def setUp(self, getJobMock): + app = test_utils.createApplication() + app.settings = qtpy.QtCore.QSettings() + cuegui.Style.init() + self.main_window = qtpy.QtWidgets.QMainWindow() + + self.job = opencue.wrappers.job.Job(opencue.compiled_proto.job_pb2.Job(id='foo')) + layer = opencue.wrappers.layer.Layer(opencue.compiled_proto.job_pb2.Layer(name='layer1')) + depend = opencue.wrappers.depend.Depend(opencue.compiled_proto.depend_pb2.Depend()) + layer.getWhatDependsOnThis = lambda: [depend] + self.job.getLayers = lambda: [layer] + self.jobGraph = cuegui.JobMonitorGraph.JobMonitorGraph(self.main_window) + self.jobGraph.setJob(self.job) + + def test_setup(self): + pass + + def test_job(self): + self.assertNotEqual(None, self.jobGraph.getJob()) From d68c93054eeffc82f4d4d03dd3daa3266db9adcc Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 02:24:42 +0200 Subject: [PATCH 12/26] Switch to use packaging instead of distutils --- cuegui/NodeGraphQt/widgets/viewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cuegui/NodeGraphQt/widgets/viewer.py b/cuegui/NodeGraphQt/widgets/viewer.py index 87584b81b..9d8489a43 100644 --- a/cuegui/NodeGraphQt/widgets/viewer.py +++ b/cuegui/NodeGraphQt/widgets/viewer.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- import math -from distutils.version import LooseVersion +from packaging import version from qtpy import QtGui, QtCore, QtWidgets @@ -201,7 +201,7 @@ def _build_context_menus(self): if self._undo_action and self._redo_action: self._undo_action.setShortcuts(QtGui.QKeySequence.StandardKey.Undo) self._redo_action.setShortcuts(QtGui.QKeySequence.StandardKey.Redo) - if LooseVersion(QtCore.qVersion()) >= LooseVersion('5.10'): + if version.parse(QtCore.qVersion()) >= version.parse('5.10'): self._undo_action.setShortcutVisibleInContextMenu(True) self._redo_action.setShortcutVisibleInContextMenu(True) From a1ead15bf75bd0e00b78b0c1cace7ca1dba940c3 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 03:11:09 +0200 Subject: [PATCH 13/26] Don't update graph if job has not been selected yet --- cuegui/cuegui/JobMonitorGraph.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cuegui/cuegui/JobMonitorGraph.py b/cuegui/cuegui/JobMonitorGraph.py index e761a50b0..4e0bbe882 100644 --- a/cuegui/cuegui/JobMonitorGraph.py +++ b/cuegui/cuegui/JobMonitorGraph.py @@ -146,7 +146,8 @@ def update(self): This is run every 20 seconds by the timer. """ - layers = self.job.getLayers() - for layer in layers: - node = self.graph.get_node_by_name(layer.name()) - node.setRpcObject(layer) + if self.job is not None: + layers = self.job.getLayers() + for layer in layers: + node = self.graph.get_node_by_name(layer.name()) + node.setRpcObject(layer) From 7f813267c3152e93da77f8d83a7fd32c7fb45ca7 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 03:26:14 +0200 Subject: [PATCH 14/26] Fix for having a single plugin in a window (.ini files are bad for saving lists) --- cuegui/cuegui/Plugins.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cuegui/cuegui/Plugins.py b/cuegui/cuegui/Plugins.py index a4d48563c..db0e1b335 100644 --- a/cuegui/cuegui/Plugins.py +++ b/cuegui/cuegui/Plugins.py @@ -191,6 +191,8 @@ def restoreState(self): # Runs any plugins that were saved to the settings openPlugins = self.app.settings.value("%s/Plugins_Opened" % self.name) or [] + if isinstance(openPlugins, str): + openPlugins = [openPlugins] for plugin in openPlugins: if '::' in plugin: plugin_name, plugin_state = str(plugin).split("::") From ea1b9f88548545a7197078205e9c140e2846971c Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Sun, 30 Jun 2024 03:39:03 +0200 Subject: [PATCH 15/26] Interpret the value of "Open" correctly in the ini file for Window state --- cuegui/cuegui/Main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cuegui/cuegui/Main.py b/cuegui/cuegui/Main.py index e46102b56..48afbb85d 100644 --- a/cuegui/cuegui/Main.py +++ b/cuegui/cuegui/Main.py @@ -80,7 +80,7 @@ def startup(app_name, app_version, argv): # Open all windows that were open when the app was last closed for name in mainWindow.windows_names[1:]: - if settings.value("%s/Open" % name, False): + if settings.value("%s/Open" % name, "false") == 'true': mainWindow.windowMenuOpenWindow(name) # End splash screen From d6d020921c92c3110cd813b0a9632d47e7c6b222 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Wed, 11 Sep 2024 21:58:17 +0200 Subject: [PATCH 16/26] Remove local copy of NodeGraphQqt and add as submodule --- .gitmodules | 4 + cuegui/NodeGraphQt | 1 + cuegui/NodeGraphQt/__init__.py | 93 - cuegui/NodeGraphQt/base/__init__.py | 0 cuegui/NodeGraphQt/base/commands.py | 500 --- cuegui/NodeGraphQt/base/factory.py | 102 - cuegui/NodeGraphQt/base/graph.py | 3056 ----------------- cuegui/NodeGraphQt/base/menu.py | 335 -- cuegui/NodeGraphQt/base/model.py | 627 ---- cuegui/NodeGraphQt/base/node.py | 529 --- cuegui/NodeGraphQt/base/port.py | 495 --- cuegui/NodeGraphQt/constants.py | 254 -- cuegui/NodeGraphQt/custom_widgets/__init__.py | 0 .../custom_widgets/nodes_palette.py | 346 -- .../NodeGraphQt/custom_widgets/nodes_tree.py | 141 - .../custom_widgets/properties_bin/__init__.py | 0 .../custom_widget_color_picker.py | 119 - .../custom_widget_file_paths.py | 76 - .../properties_bin/custom_widget_slider.py | 132 - .../custom_widget_value_edit.py | 303 -- .../properties_bin/custom_widget_vectors.py | 138 - .../properties_bin/node_property_factory.py | 60 - .../properties_bin/node_property_widgets.py | 873 ----- .../properties_bin/prop_widgets_abstract.py | 49 - .../properties_bin/prop_widgets_base.py | 305 -- cuegui/NodeGraphQt/errors.py | 26 - cuegui/NodeGraphQt/nodes/__init__.py | 0 cuegui/NodeGraphQt/nodes/backdrop_node.py | 141 - cuegui/NodeGraphQt/nodes/base_node.py | 872 ----- cuegui/NodeGraphQt/nodes/base_node_circle.py | 46 - cuegui/NodeGraphQt/nodes/group_node.py | 176 - cuegui/NodeGraphQt/nodes/port_node.py | 135 - cuegui/NodeGraphQt/pkg_info.py | 10 - cuegui/NodeGraphQt/qgraphics/__init__.py | 0 cuegui/NodeGraphQt/qgraphics/node_abstract.py | 261 -- cuegui/NodeGraphQt/qgraphics/node_backdrop.py | 311 -- cuegui/NodeGraphQt/qgraphics/node_base.py | 1056 ------ cuegui/NodeGraphQt/qgraphics/node_circle.py | 532 --- cuegui/NodeGraphQt/qgraphics/node_group.py | 317 -- .../qgraphics/node_overlay_disabled.py | 108 - cuegui/NodeGraphQt/qgraphics/node_port_in.py | 234 -- cuegui/NodeGraphQt/qgraphics/node_port_out.py | 234 -- .../NodeGraphQt/qgraphics/node_text_item.py | 117 - cuegui/NodeGraphQt/qgraphics/pipe.py | 666 ---- cuegui/NodeGraphQt/qgraphics/port.py | 325 -- cuegui/NodeGraphQt/qgraphics/slicer.py | 87 - cuegui/NodeGraphQt/widgets/__init__.py | 0 cuegui/NodeGraphQt/widgets/actions.py | 112 - cuegui/NodeGraphQt/widgets/dialogs.py | 92 - .../NodeGraphQt/widgets/icons/node_base.png | Bin 17542 -> 0 bytes cuegui/NodeGraphQt/widgets/node_graph.py | 125 - cuegui/NodeGraphQt/widgets/node_widgets.py | 448 --- cuegui/NodeGraphQt/widgets/scene.py | 171 - cuegui/NodeGraphQt/widgets/tab_search.py | 311 -- cuegui/NodeGraphQt/widgets/viewer.py | 1653 --------- cuegui/NodeGraphQt/widgets/viewer_nav.py | 198 -- external/NodeGraphQt | 1 + 57 files changed, 6 insertions(+), 17297 deletions(-) create mode 100644 .gitmodules create mode 120000 cuegui/NodeGraphQt delete mode 100644 cuegui/NodeGraphQt/__init__.py delete mode 100644 cuegui/NodeGraphQt/base/__init__.py delete mode 100644 cuegui/NodeGraphQt/base/commands.py delete mode 100644 cuegui/NodeGraphQt/base/factory.py delete mode 100644 cuegui/NodeGraphQt/base/graph.py delete mode 100644 cuegui/NodeGraphQt/base/menu.py delete mode 100644 cuegui/NodeGraphQt/base/model.py delete mode 100644 cuegui/NodeGraphQt/base/node.py delete mode 100644 cuegui/NodeGraphQt/base/port.py delete mode 100644 cuegui/NodeGraphQt/constants.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/__init__.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/nodes_palette.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/nodes_tree.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/__init__.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py delete mode 100644 cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py delete mode 100644 cuegui/NodeGraphQt/errors.py delete mode 100644 cuegui/NodeGraphQt/nodes/__init__.py delete mode 100644 cuegui/NodeGraphQt/nodes/backdrop_node.py delete mode 100644 cuegui/NodeGraphQt/nodes/base_node.py delete mode 100644 cuegui/NodeGraphQt/nodes/base_node_circle.py delete mode 100644 cuegui/NodeGraphQt/nodes/group_node.py delete mode 100644 cuegui/NodeGraphQt/nodes/port_node.py delete mode 100644 cuegui/NodeGraphQt/pkg_info.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/__init__.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_abstract.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_backdrop.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_base.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_circle.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_group.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_port_in.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_port_out.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/node_text_item.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/pipe.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/port.py delete mode 100644 cuegui/NodeGraphQt/qgraphics/slicer.py delete mode 100644 cuegui/NodeGraphQt/widgets/__init__.py delete mode 100644 cuegui/NodeGraphQt/widgets/actions.py delete mode 100644 cuegui/NodeGraphQt/widgets/dialogs.py delete mode 100644 cuegui/NodeGraphQt/widgets/icons/node_base.png delete mode 100644 cuegui/NodeGraphQt/widgets/node_graph.py delete mode 100644 cuegui/NodeGraphQt/widgets/node_widgets.py delete mode 100644 cuegui/NodeGraphQt/widgets/scene.py delete mode 100644 cuegui/NodeGraphQt/widgets/tab_search.py delete mode 100644 cuegui/NodeGraphQt/widgets/viewer.py delete mode 100644 cuegui/NodeGraphQt/widgets/viewer_nav.py create mode 160000 external/NodeGraphQt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..f3d3251bc --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "external/NodeGraphQt"] + path = external/NodeGraphQt + url = https://github.com/lithorus/NodeGraphQt.git + branch = 4d4a17e5a88a82ec436696e6c266f1c8918124d7 diff --git a/cuegui/NodeGraphQt b/cuegui/NodeGraphQt new file mode 120000 index 000000000..be2a57d06 --- /dev/null +++ b/cuegui/NodeGraphQt @@ -0,0 +1 @@ +../external/NodeGraphQt/NodeGraphQt \ No newline at end of file diff --git a/cuegui/NodeGraphQt/__init__.py b/cuegui/NodeGraphQt/__init__.py deleted file mode 100644 index 5ac835fc2..000000000 --- a/cuegui/NodeGraphQt/__init__.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -""" -**NodeGraphQt** is a node graph framework that can be implemented and re purposed -into applications that supports **PySide2**. - -project: https://github.com/jchanvfx/NodeGraphQt -documentation: https://jchanvfx.github.io/NodeGraphQt/api/html/index.html - -example code: - -.. code-block:: python - :linenos: - - from NodeGraphQt import QtWidgets, NodeGraph, BaseNode - - - class MyNode(BaseNode): - - __identifier__ = 'io.github.jchanvfx' - NODE_NAME = 'My Node' - - def __init__(self): - super(MyNode, self).__init__() - self.add_input('foo', color=(180, 80, 0)) - self.add_output('bar') - - if __name__ == '__main__': - app = QtWidgets.QApplication([]) - graph = NodeGraph() - - graph.register_node(BaseNode) - graph.register_node(BackdropNode) - - backdrop = graph.create_node('nodeGraphQt.nodes.Backdrop', name='Backdrop') - node_a = graph.create_node('io.github.jchanvfx.MyNode', name='Node A') - node_b = graph.create_node('io.github.jchanvfx.MyNode', name='Node B', color='#5b162f') - - node_a.set_input(0, node_b.output(0)) - - viewer = graph.viewer() - viewer.show() - - app.exec_() -""" -from .pkg_info import __version__ as VERSION -from .pkg_info import __license__ as LICENSE - -# node graph -from .base.graph import NodeGraph, SubGraph -from .base.menu import NodesMenu, NodeGraphMenu, NodeGraphCommand - -# nodes & ports -from .base.port import Port -from .base.node import NodeObject -from .nodes.base_node import BaseNode -from .nodes.base_node_circle import BaseNodeCircle -from .nodes.backdrop_node import BackdropNode -from .nodes.group_node import GroupNode - -# widgets -from .widgets.node_widgets import NodeBaseWidget -from .custom_widgets.nodes_tree import NodesTreeWidget -from .custom_widgets.nodes_palette import NodesPaletteWidget -from .custom_widgets.properties_bin.node_property_widgets import ( - NodePropEditorWidget, - PropertiesBinWidget -) - - -__version__ = VERSION -__all__ = [ - 'BackdropNode', - 'BaseNode', - 'BaseNodeCircle', - 'GroupNode', - 'LICENSE', - 'NodeBaseWidget', - 'NodeGraph', - 'NodeGraphCommand', - 'NodeGraphMenu', - 'NodeObject', - 'NodesPaletteWidget', - 'NodePropEditorWidget', - 'NodesTreeWidget', - 'NodesMenu', - 'Port', - 'PropertiesBinWidget', - 'SubGraph', - 'VERSION', - 'constants', - 'custom_widgets' -] diff --git a/cuegui/NodeGraphQt/base/__init__.py b/cuegui/NodeGraphQt/base/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cuegui/NodeGraphQt/base/commands.py b/cuegui/NodeGraphQt/base/commands.py deleted file mode 100644 index 0dbd16cdb..000000000 --- a/cuegui/NodeGraphQt/base/commands.py +++ /dev/null @@ -1,500 +0,0 @@ -#!/usr/bin/python -from qtpy import QtWidgets - -from NodeGraphQt.constants import PortTypeEnum - - -class PropertyChangedCmd(QtWidgets.QUndoCommand): - """ - Node property changed command. - - Args: - node (NodeGraphQt.NodeObject): node. - name (str): node property name. - value (object): node property value. - """ - - def __init__(self, node, name, value): - QtWidgets.QUndoCommand.__init__(self) - self.setText('property "{}:{}"'.format(node.name(), name)) - self.node = node - self.name = name - self.old_val = node.get_property(name) - self.new_val = value - - def set_node_property(self, name, value): - """ - updates the node view and model. - """ - # set model data. - model = self.node.model - model.set_property(name, value) - - # set view data. - view = self.node.view - - # view widgets. - if hasattr(view, 'widgets') and name in view.widgets.keys(): - # check if previous value is identical to current value, - # prevent signals from causing an infinite loop. - if view.widgets[name].get_value() != value: - view.widgets[name].set_value(value) - - # view properties. - if name in view.properties.keys(): - # remap "pos" to "xy_pos" node view has pre-existing pos method. - if name == 'pos': - name = 'xy_pos' - setattr(view, name, value) - - # emit property changed signal. - graph = self.node.graph - graph.property_changed.emit(self.node, self.name, value) - - def undo(self): - if self.old_val != self.new_val: - self.set_node_property(self.name, self.old_val) - - def redo(self): - if self.old_val != self.new_val: - self.set_node_property(self.name, self.new_val) - - -class NodeVisibleCmd(QtWidgets.QUndoCommand): - """ - Node visibility changed command. - - Args: - node (NodeGraphQt.NodeObject): node. - visible (bool): node visible value. - """ - - def __init__(self, node, visible): - QtWidgets.QUndoCommand.__init__(self) - self.node = node - self.visible = visible - self.selected = self.node.selected() - - def set_node_visible(self, visible): - model = self.node.model - model.set_property('visible', visible) - - node_view = self.node.view - node_view.visible = visible - - # redraw the connected pipes in the scene. - ports = node_view.inputs + node_view.outputs - for port in ports: - for pipe in port.connected_pipes: - pipe.update() - - # restore the node selected state. - if self.selected != node_view.isSelected(): - node_view.setSelected(model.selected) - - # emit property changed signal. - graph = self.node.graph - graph.property_changed.emit(self.node, 'visible', visible) - - def undo(self): - self.set_node_visible(not self.visible) - - def redo(self): - self.set_node_visible(self.visible) - - -class NodeWidgetVisibleCmd(QtWidgets.QUndoCommand): - """ - Node widget visibility command. - - Args: - node (NodeGraphQt.NodeObject): node object. - name (str): node widget name. - visible (bool): initial visibility state. - """ - - def __init__(self, node, name, visible): - QtWidgets.QUndoCommand.__init__(self) - label = 'show' if visible else 'hide' - self.setText('{} node widget "{}"'.format(label, name)) - self.view = node.view - self.node_widget = self.view.get_widget(name) - self.visible = visible - - def undo(self): - self.node_widget.setVisible(not self.visible) - self.view.draw_node() - - def redo(self): - self.node_widget.setVisible(self.visible) - self.view.draw_node() - - -class NodeMovedCmd(QtWidgets.QUndoCommand): - """ - Node moved command. - - Args: - node (NodeGraphQt.NodeObject): node. - pos (tuple(float, float)): new node position. - prev_pos (tuple(float, float)): previous node position. - """ - - def __init__(self, node, pos, prev_pos): - QtWidgets.QUndoCommand.__init__(self) - self.node = node - self.pos = pos - self.prev_pos = prev_pos - - def undo(self): - self.node.view.xy_pos = self.prev_pos - self.node.model.pos = self.prev_pos - - def redo(self): - if self.pos == self.prev_pos: - return - self.node.view.xy_pos = self.pos - self.node.model.pos = self.pos - - -class NodeAddedCmd(QtWidgets.QUndoCommand): - """ - Node added command. - - Args: - graph (NodeGraphQt.NodeGraph): node graph. - node (NodeGraphQt.NodeObject): node. - pos (tuple(float, float)): initial node position (optional). - emit_signal (bool): emit node creation signals. (default: True) - """ - - def __init__(self, graph, node, pos=None, emit_signal=True): - QtWidgets.QUndoCommand.__init__(self) - self.setText('added node') - self.graph = graph - self.node = node - self.pos = pos - self.emit_signal = emit_signal - - def undo(self): - node_id = self.node.id - self.pos = self.pos or self.node.pos() - self.graph.model.nodes.pop(self.node.id) - self.node.view.delete() - - if self.emit_signal: - self.graph.nodes_deleted.emit([node_id]) - - def redo(self): - self.graph.model.nodes[self.node.id] = self.node - self.graph.viewer().add_node(self.node.view, self.pos) - - # node width & height is calculated when it's added to the scene, - # so we have to update the node model here. - self.node.model.width = self.node.view.width - self.node.model.height = self.node.view.height - - if self.emit_signal: - self.graph.node_created.emit(self.node) - - -class NodesRemovedCmd(QtWidgets.QUndoCommand): - """ - Node deleted command. - - Args: - graph (NodeGraphQt.NodeGraph): node graph. - nodes (list[NodeGraphQt.BaseNode or NodeGraphQt.NodeObject]): nodes. - emit_signal (bool): emit node deletion signals. (default: True) - """ - - def __init__(self, graph, nodes, emit_signal=True): - QtWidgets.QUndoCommand.__init__(self) - self.setText('deleted node(s)') - self.graph = graph - self.nodes = nodes - self.emit_signal = emit_signal - - def undo(self): - for node in self.nodes: - self.graph.model.nodes[node.id] = node - self.graph.scene().addItem(node.view) - - if self.emit_signal: - self.graph.node_created.emit(node) - - def redo(self): - node_ids = [] - for node in self.nodes: - node_ids.append(node.id) - self.graph.model.nodes.pop(node.id) - node.view.delete() - - if self.emit_signal: - self.graph.nodes_deleted.emit(node_ids) - - -class NodeInputConnectedCmd(QtWidgets.QUndoCommand): - """ - "BaseNode.on_input_connected()" command. - - Args: - src_port (NodeGraphQt.Port): source port. - trg_port (NodeGraphQt.Port): target port. - """ - - def __init__(self, src_port, trg_port): - QtWidgets.QUndoCommand.__init__(self) - if src_port.type_() == PortTypeEnum.IN.value: - self.source = src_port - self.target = trg_port - else: - self.source = trg_port - self.target = src_port - - def undo(self): - node = self.source.node() - node.on_input_disconnected(self.source, self.target) - - def redo(self): - node = self.source.node() - node.on_input_connected(self.source, self.target) - - -class NodeInputDisconnectedCmd(QtWidgets.QUndoCommand): - """ - Node "on_input_disconnected()" command. - - Args: - src_port (NodeGraphQt.Port): source port. - trg_port (NodeGraphQt.Port): target port. - """ - - def __init__(self, src_port, trg_port): - QtWidgets.QUndoCommand.__init__(self) - if src_port.type_() == PortTypeEnum.IN.value: - self.source = src_port - self.target = trg_port - else: - self.source = trg_port - self.target = src_port - - def undo(self): - node = self.source.node() - node.on_input_connected(self.source, self.target) - - def redo(self): - node = self.source.node() - node.on_input_disconnected(self.source, self.target) - - -class PortConnectedCmd(QtWidgets.QUndoCommand): - """ - Port connected command. - - Args: - src_port (NodeGraphQt.Port): source port. - trg_port (NodeGraphQt.Port): target port. - emit_signal (bool): emit port connection signals. - """ - - def __init__(self, src_port, trg_port, emit_signal): - QtWidgets.QUndoCommand.__init__(self) - self.source = src_port - self.target = trg_port - self.emit_signal = emit_signal - - def undo(self): - src_model = self.source.model - trg_model = self.target.model - src_id = self.source.node().id - trg_id = self.target.node().id - - port_names = src_model.connected_ports.get(trg_id) - if port_names is []: - del src_model.connected_ports[trg_id] - if port_names and self.target.name() in port_names: - port_names.remove(self.target.name()) - - port_names = trg_model.connected_ports.get(src_id) - if port_names is []: - del trg_model.connected_ports[src_id] - if port_names and self.source.name() in port_names: - port_names.remove(self.source.name()) - - self.source.view.disconnect_from(self.target.view) - - # emit "port_disconnected" signal from the parent graph. - if self.emit_signal: - ports = {p.type_(): p for p in [self.source, self.target]} - graph = self.source.node().graph - graph.port_disconnected.emit(ports[PortTypeEnum.IN.value], - ports[PortTypeEnum.OUT.value]) - - def redo(self): - src_model = self.source.model - trg_model = self.target.model - src_id = self.source.node().id - trg_id = self.target.node().id - - src_model.connected_ports[trg_id].append(self.target.name()) - trg_model.connected_ports[src_id].append(self.source.name()) - - self.source.view.connect_to(self.target.view) - - # emit "port_connected" signal from the parent graph. - if self.emit_signal: - ports = {p.type_(): p for p in [self.source, self.target]} - graph = self.source.node().graph - graph.port_connected.emit(ports[PortTypeEnum.IN.value], - ports[PortTypeEnum.OUT.value]) - - -class PortDisconnectedCmd(QtWidgets.QUndoCommand): - """ - Port disconnected command. - - Args: - src_port (NodeGraphQt.Port): source port. - trg_port (NodeGraphQt.Port): target port. - emit_signal (bool): emit port connection signals. - """ - - def __init__(self, src_port, trg_port, emit_signal): - QtWidgets.QUndoCommand.__init__(self) - self.source = src_port - self.target = trg_port - self.emit_signal = emit_signal - - def undo(self): - src_model = self.source.model - trg_model = self.target.model - src_id = self.source.node().id - trg_id = self.target.node().id - - src_model.connected_ports[trg_id].append(self.target.name()) - trg_model.connected_ports[src_id].append(self.source.name()) - - self.source.view.connect_to(self.target.view) - - # emit "port_connected" signal from the parent graph. - if self.emit_signal: - ports = {p.type_(): p for p in [self.source, self.target]} - graph = self.source.node().graph - graph.port_connected.emit(ports[PortTypeEnum.IN.value], - ports[PortTypeEnum.OUT.value]) - - def redo(self): - src_model = self.source.model - trg_model = self.target.model - src_id = self.source.node().id - trg_id = self.target.node().id - - port_names = src_model.connected_ports.get(trg_id) - if port_names is []: - del src_model.connected_ports[trg_id] - if port_names and self.target.name() in port_names: - port_names.remove(self.target.name()) - - port_names = trg_model.connected_ports.get(src_id) - if port_names is []: - del trg_model.connected_ports[src_id] - if port_names and self.source.name() in port_names: - port_names.remove(self.source.name()) - - self.source.view.disconnect_from(self.target.view) - - # emit "port_disconnected" signal from the parent graph. - if self.emit_signal: - ports = {p.type_(): p for p in [self.source, self.target]} - graph = self.source.node().graph - graph.port_disconnected.emit(ports[PortTypeEnum.IN.value], - ports[PortTypeEnum.OUT.value]) - - -class PortLockedCmd(QtWidgets.QUndoCommand): - """ - Port locked command. - - Args: - port (NodeGraphQt.Port): node port. - """ - - def __init__(self, port): - QtWidgets.QUndoCommand.__init__(self) - self.setText('lock port "{}"'.format(port.name())) - self.port = port - - def undo(self): - self.port.model.locked = False - self.port.view.locked = False - - def redo(self): - self.port.model.locked = True - self.port.view.locked = True - - -class PortUnlockedCmd(QtWidgets.QUndoCommand): - """ - Port unlocked command. - - Args: - port (NodeGraphQt.Port): node port. - """ - - def __init__(self, port): - QtWidgets.QUndoCommand.__init__(self) - self.setText('unlock port "{}"'.format(port.name())) - self.port = port - - def undo(self): - self.port.model.locked = True - self.port.view.locked = True - - def redo(self): - self.port.model.locked = False - self.port.view.locked = False - - -class PortVisibleCmd(QtWidgets.QUndoCommand): - """ - Port visibility command. - - Args: - port (NodeGraphQt.Port): node port. - """ - - def __init__(self, port, visible): - QtWidgets.QUndoCommand.__init__(self) - self.port = port - self.visible = visible - if visible: - self.setText('show port {}'.format(self.port.name())) - else: - self.setText('hide port {}'.format(self.port.name())) - - def set_visible(self, visible): - self.port.model.visible = visible - self.port.view.setVisible(visible) - node_view = self.port.node().view - text_item = None - if self.port.type_() == PortTypeEnum.IN.value: - text_item = node_view.get_input_text_item(self.port.view) - elif self.port.type_() == PortTypeEnum.OUT.value: - text_item = node_view.get_output_text_item(self.port.view) - if text_item: - text_item.setVisible(visible) - - node_view.draw_node() - - # redraw the connected pipes in the scene. - ports = node_view.inputs + node_view.outputs - for port in node_view.inputs + node_view.outputs: - for pipe in port.connected_pipes: - pipe.update() - - def undo(self): - self.set_visible(not self.visible) - - def redo(self): - self.set_visible(self.visible) diff --git a/cuegui/NodeGraphQt/base/factory.py b/cuegui/NodeGraphQt/base/factory.py deleted file mode 100644 index ac209c75c..000000000 --- a/cuegui/NodeGraphQt/base/factory.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/python -from NodeGraphQt.errors import NodeRegistrationError - - -class NodeFactory(object): - """ - Node factory that stores all the node types. - """ - - def __init__(self): - self.__aliases = {} - self.__names = {} - self.__nodes = {} - - @property - def names(self): - """ - Return all currently registered node type identifiers. - - Returns: - dict: key='.format( - self.__class__.__name__, hex(id(self))) - - def _register_context_menu(self): - """ - Register the default context menus. - """ - if not self._viewer: - return - menus = self._viewer.context_menus() - if menus.get('graph'): - self._context_menu['graph'] = NodeGraphMenu(self, menus['graph']) - if menus.get('nodes'): - self._context_menu['nodes'] = NodesMenu(self, menus['nodes']) - - def _register_builtin_nodes(self): - """ - Register the default builtin nodes to the :meth:`NodeGraph.node_factory` - """ - self.register_node(BackdropNode, alias='Backdrop') - - def _wire_signals(self): - """ - Connect up all the signals and slots here. - """ - - # internal signals. - self._viewer.search_triggered.connect(self._on_search_triggered) - self._viewer.connection_sliced.connect(self._on_connection_sliced) - self._viewer.connection_changed.connect(self._on_connection_changed) - self._viewer.moved_nodes.connect(self._on_nodes_moved) - self._viewer.node_double_clicked.connect(self._on_node_double_clicked) - self._viewer.node_name_changed.connect(self._on_node_name_changed) - self._viewer.node_backdrop_updated.connect( - self._on_node_backdrop_updated) - self._viewer.insert_node.connect(self._on_insert_node) - - # pass through translated signals. - self._viewer.node_selected.connect(self._on_node_selected) - self._viewer.node_selection_changed.connect( - self._on_node_selection_changed) - self._viewer.data_dropped.connect(self._on_node_data_dropped) - self._viewer.context_menu_prompt.connect(self._on_context_menu_prompt) - - def _on_context_menu_prompt(self, menu_name, node_id): - """ - Slot function triggered just before a context menu is shown. - - Args: - menu_name (str): context menu name. - node_id (str): node id if triggered from the nodes context menu. - """ - node = self.get_node_by_id(node_id) - menu = self.get_context_menu(menu_name) - self.context_menu_prompt.emit(menu, node) - - def _on_insert_node(self, pipe, node_id, prev_node_pos): - """ - Slot function triggered when a selected node has collided with a pipe. - - Args: - pipe (Pipe): collided pipe item. - node_id (str): selected node id to insert. - prev_node_pos (dict): previous node position. {NodeItem: [prev_x, prev_y]} - """ - node = self.get_node_by_id(node_id) - - # exclude if not a BaseNode - if not isinstance(node, BaseNode): - return - - disconnected = [(pipe.input_port, pipe.output_port)] - connected = [] - - if node.input_ports(): - connected.append( - (pipe.output_port, node.input_ports()[0].view) - ) - if node.output_ports(): - connected.append( - (node.output_ports()[0].view, pipe.input_port) - ) - - self._undo_stack.beginMacro('inserted node') - self._on_connection_changed(disconnected, connected) - self._on_nodes_moved(prev_node_pos) - self._undo_stack.endMacro() - - def _on_property_bin_changed(self, node_id, prop_name, prop_value): - """ - called when a property widget has changed in a properties bin. - (emits the node object, property name, property value) - - Args: - node_id (str): node id. - prop_name (str): node property name. - prop_value (object): python built in types. - """ - node = self.get_node_by_id(node_id) - - # prevent signals from causing a infinite loop. - if node.get_property(prop_name) != prop_value: - node.set_property(prop_name, prop_value) - - def _on_node_name_changed(self, node_id, name): - """ - called when a node text qgraphics item in the viewer is edited. - (sets the name through the node object so undo commands are registered.) - - Args: - node_id (str): node id emitted by the viewer. - name (str): new node name. - """ - node = self.get_node_by_id(node_id) - node.set_name(name) - - # TODO: not sure about redrawing the node here. - node.view.draw_node() - - def _on_node_double_clicked(self, node_id): - """ - called when a node in the viewer is double click. - (emits the node object when the node is clicked) - - Args: - node_id (str): node id emitted by the viewer. - """ - node = self.get_node_by_id(node_id) - self.node_double_clicked.emit(node) - - def _on_node_selected(self, node_id): - """ - called when a node in the viewer is selected on left click. - (emits the node object when the node is clicked) - - Args: - node_id (str): node id emitted by the viewer. - """ - node = self.get_node_by_id(node_id) - self.node_selected.emit(node) - - def _on_node_selection_changed(self, sel_ids, desel_ids): - """ - called when the node selection changes in the viewer. - (emits node objects , ) - - Args: - sel_ids (list[str]): new selected node ids. - desel_ids (list[str]): deselected node ids. - """ - sel_nodes = [self.get_node_by_id(nid) for nid in sel_ids] - unsel_nodes = [self.get_node_by_id(nid) for nid in desel_ids] - self.node_selection_changed.emit(sel_nodes, unsel_nodes) - - def _on_node_data_dropped(self, mimedata, pos): - """ - called when data has been dropped on the viewer. - - Example Identifiers: - URI = ngqt://path/to/node/session.graph - URN = ngqt::node:com.nodes.MyNode1;node:com.nodes.MyNode2 - - Args: - mimedata (QtCore.QMimeData): mime data. - pos (QtCore.QPoint): scene position relative to the drop. - """ - uri_regex = re.compile(r'{}(?:/*)([\w/]+)(\.\w+)'.format(URI_SCHEME)) - urn_regex = re.compile(r'{}([\w\.:;]+)'.format(URN_SCHEME)) - if mimedata.hasFormat(MIME_TYPE): - data = mimedata.data(MIME_TYPE).data().decode() - urn_search = urn_regex.search(data) - if urn_search: - search_str = urn_search.group(1) - node_ids = sorted(re.findall(r'node:([\w\.]+)', search_str)) - x, y = pos.x(), pos.y() - for node_id in node_ids: - self.create_node(node_id, pos=[x, y]) - x += 80 - y += 80 - elif mimedata.hasFormat('text/uri-list'): - not_supported_urls = [] - for url in mimedata.urls(): - local_file = url.toLocalFile() - if local_file: - try: - self.import_session(local_file) - continue - except Exception as e: - not_supported_urls.append(url) - - url_str = url.toString() - if url_str: - uri_search = uri_regex.search(url_str) - if uri_search: - path = uri_search.group(1) - ext = uri_search.group(2) - try: - self.import_session('{}{}'.format(path, ext)) - except Exception as e: - not_supported_urls.append(url) - - if not_supported_urls: - print( - 'Can\'t import the following urls: \n{}' - .format('\n'.join(not_supported_urls)) - ) - self.data_dropped.emit(mimedata, pos) - else: - self.data_dropped.emit(mimedata, pos) - - def _on_nodes_moved(self, node_data): - """ - called when selected nodes in the viewer has changed position. - - Args: - node_data (dict): {: } - """ - self._undo_stack.beginMacro('move nodes') - for node_view, prev_pos in node_data.items(): - node = self._model.nodes[node_view.id] - self._undo_stack.push(NodeMovedCmd(node, node.pos(), prev_pos)) - self._undo_stack.endMacro() - - def _on_node_backdrop_updated(self, node_id, update_property, value): - """ - called when a BackdropNode is updated. - - Args: - node_id (str): backdrop node id. - value (str): update type. - """ - backdrop = self.get_node_by_id(node_id) - if backdrop and isinstance(backdrop, BackdropNode): - backdrop.on_backdrop_updated(update_property, value) - - def _on_search_triggered(self, node_type, pos): - """ - called when the tab search widget is triggered in the viewer. - - Args: - node_type (str): node identifier. - pos (tuple or list): x, y position for the node. - """ - self.create_node(node_type, pos=pos) - - def _on_connection_changed(self, disconnected, connected): - """ - called when a pipe connection has been changed in the viewer. - - Args: - disconnected (list[list[widgets.port.PortItem]): - pair list of port view items. - connected (list[list[widgets.port.PortItem]]): - pair list of port view items. - """ - if not (disconnected or connected): - return - - label = 'connect node(s)' if connected else 'disconnect node(s)' - ptypes = {PortTypeEnum.IN.value: 'inputs', - PortTypeEnum.OUT.value: 'outputs'} - - self._undo_stack.beginMacro(label) - for p1_view, p2_view in disconnected: - node1 = self._model.nodes[p1_view.node.id] - node2 = self._model.nodes[p2_view.node.id] - port1 = getattr(node1, ptypes[p1_view.port_type])()[p1_view.name] - port2 = getattr(node2, ptypes[p2_view.port_type])()[p2_view.name] - port1.disconnect_from(port2) - for p1_view, p2_view in connected: - node1 = self._model.nodes[p1_view.node.id] - node2 = self._model.nodes[p2_view.node.id] - port1 = getattr(node1, ptypes[p1_view.port_type])()[p1_view.name] - port2 = getattr(node2, ptypes[p2_view.port_type])()[p2_view.name] - port1.connect_to(port2) - self._undo_stack.endMacro() - - def _on_connection_sliced(self, ports): - """ - slot when connection pipes have been sliced. - - Args: - ports (list[list[widgets.port.PortItem]]): - pair list of port connections (in port, out port) - """ - if not ports: - return - ptypes = {PortTypeEnum.IN.value: 'inputs', - PortTypeEnum.OUT.value: 'outputs'} - self._undo_stack.beginMacro('slice connections') - for p1_view, p2_view in ports: - node1 = self._model.nodes[p1_view.node.id] - node2 = self._model.nodes[p2_view.node.id] - port1 = getattr(node1, ptypes[p1_view.port_type])()[p1_view.name] - port2 = getattr(node2, ptypes[p2_view.port_type])()[p2_view.name] - port1.disconnect_from(port2) - self._undo_stack.endMacro() - - @property - def model(self): - """ - The model used for storing the node graph data. - - Returns: - NodeGraphQt.base.model.NodeGraphModel: node graph model. - """ - return self._model - - @property - def node_factory(self): - """ - Return the node factory object used by the node graph. - - Returns: - NodeFactory: node factory. - """ - return self._node_factory - - @property - def widget(self): - """ - The node graph widget for adding into a layout. - - Returns: - NodeGraphWidget: node graph widget. - """ - if self._widget is None: - self._widget = NodeGraphWidget() - self._widget.addTab(self._viewer, 'Node Graph') - # hide the close button on the first tab. - tab_bar = self._widget.tabBar() - for btn_flag in [tab_bar.ButtonPosition.RightSide, tab_bar.ButtonPosition.LeftSide]: - tab_btn = tab_bar.tabButton(0, btn_flag) - if tab_btn: - tab_btn.deleteLater() - tab_bar.setTabButton(0, btn_flag, None) - self._widget.tabCloseRequested.connect( - self._on_close_sub_graph_tab - ) - return self._widget - - @property - def undo_view(self): - """ - Returns node graph undo history list widget. - - Returns: - PySide2.QtWidgets.QUndoView: node graph undo view. - """ - if self._undo_view is None: - self._undo_view = QtWidgets.QUndoView(self._undo_stack) - self._undo_view.setWindowTitle('Undo History') - return self._undo_view - - def cursor_pos(self): - """ - Returns the cursor last position in the node graph. - - Returns: - tuple(float, float): cursor x,y coordinates of the scene. - """ - cursor_pos = self.viewer().scene_cursor_pos() - if not cursor_pos: - return 0.0, 0.0 - return cursor_pos.x(), cursor_pos.y() - - def toggle_node_search(self): - """ - toggle the node search widget visibility. - """ - if self._viewer.underMouse(): - self._viewer.tab_search_set_nodes(self._node_factory.names) - self._viewer.tab_search_toggle() - - def show(self): - """ - Show node graph widget this is just a convenience - function to :meth:`NodeGraph.widget.show()`. - """ - self.widget.show() - - def close(self): - """ - Close node graph NodeViewer widget this is just a convenience - function to :meth:`NodeGraph.widget.close()`. - """ - self.widget.close() - - def viewer(self): - """ - Returns the internal view interface used by the node graph. - - Warnings: - Methods in the ``NodeViewer`` are used internally - by ``NodeGraphQt`` components to get the widget use - :attr:`NodeGraph.widget`. - - See Also: - :attr:`NodeGraph.widget` to add the node graph widget into a - :class:`PySide2.QtWidgets.QLayout`. - - Returns: - NodeGraphQt.widgets.viewer.NodeViewer: viewer interface. - """ - return self._viewer - - def scene(self): - """ - Returns the ``QGraphicsScene`` object used in the node graph. - - Returns: - NodeGraphQt.widgets.scene.NodeScene: node scene. - """ - return self._viewer.scene() - - def background_color(self): - """ - Return the node graph background color. - - Returns: - tuple: r, g ,b - """ - return self.scene().background_color - - def set_background_color(self, r, g, b): - """ - Set node graph background color. - - Args: - r (int): red value. - g (int): green value. - b (int): blue value. - """ - self.scene().background_color = (r, g, b) - self._viewer.force_update() - - def grid_color(self): - """ - Return the node graph grid color. - - Returns: - tuple: r, g ,b - """ - return self.scene().grid_color - - def set_grid_color(self, r, g, b): - """ - Set node graph grid color. - - Args: - r (int): red value. - g (int): green value. - b (int): blue value. - """ - self.scene().grid_color = (r, g, b) - self._viewer.force_update() - - def set_grid_mode(self, mode=None): - """ - Set node graph background grid mode. - - (default: :attr:`NodeGraphQt.constants.ViewerEnum.GRID_DISPLAY_LINES`). - - See: :attr:`NodeGraphQt.constants.ViewerEnum` - - .. code-block:: python - :linenos: - - graph = NodeGraph() - graph.set_grid_mode(ViewerEnum.GRID_DISPLAY_DOTS.value) - - Args: - mode (int): background style. - """ - display_types = [ - ViewerEnum.GRID_DISPLAY_NONE.value, - ViewerEnum.GRID_DISPLAY_DOTS.value, - ViewerEnum.GRID_DISPLAY_LINES.value - ] - if mode not in display_types: - mode = ViewerEnum.GRID_DISPLAY_LINES.value - self.scene().grid_mode = mode - self._viewer.force_update() - - def add_properties_bin(self, prop_bin): - """ - Wire up a properties bin widget to the node graph. - - Args: - prop_bin (NodeGraphQt.PropertiesBinWidget): properties widget. - """ - prop_bin.property_changed.connect(self._on_property_bin_changed) - - def undo_stack(self): - """ - Returns the undo stack used in the node graph. - - See Also: - :meth:`NodeGraph.begin_undo()`, - :meth:`NodeGraph.end_undo()` - - Returns: - QtWidgets.QUndoStack: undo stack. - """ - return self._undo_stack - - def clear_undo_stack(self): - """ - Clears the undo stack. - - Note: - Convenience function to - :meth:`NodeGraph.undo_stack().clear()` - - See Also: - :meth:`NodeGraph.begin_undo()`, - :meth:`NodeGraph.end_undo()`, - :meth:`NodeGraph.undo_stack()` - """ - self._undo_stack.clear() - - def begin_undo(self, name): - """ - Start of an undo block followed by a - :meth:`NodeGraph.end_undo()`. - - Args: - name (str): name for the undo block. - """ - self._undo_stack.beginMacro(name) - - def end_undo(self): - """ - End of an undo block started by - :meth:`NodeGraph.begin_undo()`. - """ - self._undo_stack.endMacro() - - def context_menu(self): - """ - Returns the context menu for the node graph. - - Note: - This is a convenience function to - :meth:`NodeGraph.get_context_menu` - with the arg ``menu="graph"`` - - Returns: - NodeGraphQt.NodeGraphMenu: context menu object. - """ - return self.get_context_menu('graph') - - def context_nodes_menu(self): - """ - Returns the context menu for the nodes. - - Note: - This is a convenience function to - :meth:`NodeGraph.get_context_menu` - with the arg ``menu="nodes"`` - - Returns: - NodeGraphQt.NodesMenu: context menu object. - """ - return self.get_context_menu('nodes') - - def get_context_menu(self, menu): - """ - Returns the context menu specified by the name. - - menu types: - - - ``"graph"`` context menu from the node graph. - - ``"nodes"`` context menu for the nodes. - - Args: - menu (str): menu name. - - Returns: - NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu: context menu object. - """ - return self._context_menu.get(menu) - - def _deserialize_context_menu(self, menu, menu_data): - """ - Populate context menu from a dictionary. - - Args: - menu (NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu): - parent context menu. - menu_data (list[dict] or dict): serialized menu data. - """ - if not menu: - raise ValueError('No context menu named: "{}"'.format(menu)) - - import sys - import importlib.util - - nodes_menu = self.get_context_menu('nodes') - - def build_menu_command(menu, data): - """ - Create menu command from serialized data. - - Args: - menu (NodeGraphQt.NodeGraphMenu or NodeGraphQt.NodesMenu): - menu object. - data (dict): serialized menu command data. - """ - full_path = os.path.abspath(data['file']) - base_dir, file_name = os.path.split(full_path) - base_name = os.path.basename(base_dir) - file_name, _ = file_name.split('.') - - mod_name = '{}.{}'.format(base_name, file_name) - - spec = importlib.util.spec_from_file_location(mod_name, full_path) - mod = importlib.util.module_from_spec(spec) - sys.modules[mod_name] = mod - spec.loader.exec_module(mod) - - cmd_func = getattr(mod, data['function_name']) - cmd_name = data.get('label') or '' - cmd_shortcut = data.get('shortcut') - cmd_kwargs = {'func': cmd_func, 'shortcut': cmd_shortcut} - - if menu == nodes_menu and data.get('node_type'): - cmd_kwargs['node_type'] = data['node_type'] - - menu.add_command(name=cmd_name, **cmd_kwargs) - - if isinstance(menu_data, dict): - item_type = menu_data.get('type') - if item_type == 'separator': - menu.add_separator() - elif item_type == 'command': - build_menu_command(menu, menu_data) - elif item_type == 'menu': - sub_menu = menu.add_menu(menu_data['label']) - items = menu_data.get('items', []) - self._deserialize_context_menu(sub_menu, items) - elif isinstance(menu_data, list): - for item_data in menu_data: - self._deserialize_context_menu(menu, item_data) - - def set_context_menu(self, menu_name, data): - """ - Populate a context menu from serialized data. - - example of serialized menu data: - - .. highlight:: python - .. code-block:: python - - [ - { - 'type': 'menu', - 'label': 'node sub menu', - 'items': [ - { - 'type': 'command', - 'label': 'test command', - 'file': '../path/to/my/test_module.py', - 'function': 'run_test', - 'node_type': 'nodeGraphQt.nodes.MyNodeClass' - }, - - ] - }, - ] - - the ``run_test`` example function: - - .. highlight:: python - .. code-block:: python - - def run_test(graph): - print(graph.selected_nodes()) - - - Args: - menu_name (str): name of the parent context menu to populate under. - data (dict): serialized menu data. - """ - context_menu = self.get_context_menu(menu_name) - self._deserialize_context_menu(context_menu, data) - - def set_context_menu_from_file(self, file_path, menu=None): - """ - Populate a context menu from a serialized json file. - - menu types: - - - ``"graph"`` context menu from the node graph. - - ``"nodes"`` context menu for the nodes. - - Args: - menu (str): name of the parent context menu to populate under. - file_path (str): serialized menu commands json file. - """ - file_path = os.path.abspath(file_path) - - menu = menu or 'graph' - if not os.path.isfile(file_path): - raise IOError('file doesn\'t exists: "{}"'.format(file_path)) - - with open(file_path) as f: - data = json.load(f) - context_menu = self.get_context_menu(menu) - self._deserialize_context_menu(context_menu, data) - - def disable_context_menu(self, disabled=True, name='all'): - """ - Disable/Enable context menus from the node graph. - - menu types: - - - ``"all"`` all context menus from the node graph. - - ``"graph"`` context menu from the node graph. - - ``"nodes"`` context menu for the nodes. - - Args: - disabled (bool): true to enable context menu. - name (str): menu name. (default: ``"all"``) - """ - if name == 'all': - for k, menu in self._viewer.context_menus().items(): - menu.setDisabled(disabled) - menu.setVisible(not disabled) - return - menus = self._viewer.context_menus() - if menus.get(name): - menus[name].setDisabled(disabled) - menus[name].setVisible(not disabled) - - def acyclic(self): - """ - Returns true if the current node graph is acyclic. - - See Also: - :meth:`NodeGraph.set_acyclic` - - Returns: - bool: true if acyclic (default: ``True``). - """ - return self._model.acyclic - - def set_acyclic(self, mode=True): - """ - Enable the node graph to be a acyclic graph. (default: ``True``) - - See Also: - :meth:`NodeGraph.acyclic` - - Args: - mode (bool): true to enable acyclic. - """ - self._model.acyclic = mode - self._viewer.acyclic = self._model.acyclic - - def pipe_collision(self): - """ - Returns if pipe collision is enabled. - - See Also: - To enable/disable pipe collision - :meth:`NodeGraph.set_pipe_collision` - - Returns: - bool: True if pipe collision is enabled. - """ - return self._model.pipe_collision - - def set_pipe_collision(self, mode=True): - """ - Enable/Disable pipe collision. - - When enabled dragging a node over a pipe will allow the node to be - inserted as a new connection between the pipe. - - See Also: - :meth:`NodeGraph.pipe_collision` - - Args: - mode (bool): False to disable pipe collision. - """ - self._model.pipe_collision = mode - self._viewer.pipe_collision = self._model.pipe_collision - - def pipe_slicing(self): - """ - Returns if pipe slicing is enabled. - - See Also: - To enable/disable pipe slicer - :meth:`NodeGraph.set_pipe_slicing` - - Returns: - bool: True if pipe slicing is enabled. - """ - return self._model.pipe_slicing - - def set_pipe_slicing(self, mode=True): - """ - Enable/Disable pipe slicer. - - When set to true holding down ``Alt + Shift + LMB Drag`` will allow node - pipe connections to be sliced. - - .. image:: ../_images/slicer.png - :width: 400px - - See Also: - :meth:`NodeGraph.pipe_slicing` - - Args: - mode (bool): False to disable the slicer pipe. - """ - self._model.pipe_slicing = mode - self._viewer.pipe_slicing = self._model.pipe_slicing - - def pipe_style(self): - """ - Returns the current pipe layout style. - - See Also: - :meth:`NodeGraph.set_pipe_style` - - Returns: - int: pipe style value. :attr:`NodeGraphQt.constants.PipeLayoutEnum` - """ - return self._model.pipe_style - - def set_pipe_style(self, style=PipeLayoutEnum.CURVED.value): - """ - Set node graph pipes to be drawn as curved `(default)`, straight or angled. - - .. code-block:: python - :linenos: - - graph = NodeGraph() - graph.set_pipe_style(PipeLayoutEnum.CURVED.value) - - See: :attr:`NodeGraphQt.constants.PipeLayoutEnum` - - .. image:: ../_images/pipe_layout_types.gif - :width: 80% - - - Args: - style (int): pipe layout style. - """ - pipe_max = max([PipeLayoutEnum.CURVED.value, - PipeLayoutEnum.STRAIGHT.value, - PipeLayoutEnum.ANGLE.value]) - style = style if 0 <= style <= pipe_max else PipeLayoutEnum.CURVED.value - self._model.pipe_style = style - self._viewer.set_pipe_layout(style) - - def layout_direction(self): - """ - Return the current node graph layout direction. - - `Implemented in` ``v0.3.0`` - - See Also: - :meth:`NodeGraph.set_layout_direction` - - Returns: - int: layout direction. - """ - return self._model.layout_direction - - def set_layout_direction(self, direction): - """ - Sets the node graph layout direction to horizontal or vertical. - This function will also override the layout direction on all - nodes in the current node graph. - - `Implemented in` ``v0.3.0`` - - **Layout Types:** - - - :attr:`NodeGraphQt.constants.LayoutDirectionEnum.HORIZONTAL` - - :attr:`NodeGraphQt.constants.LayoutDirectionEnum.VERTICAL` - - .. image:: ../_images/layout_direction_switch.gif - :width: 300px - - Warnings: - This function does not register to the undo stack. - - See Also: - :meth:`NodeGraph.layout_direction`, - :meth:`NodeObject.set_layout_direction` - - Args: - direction (int): layout direction. - """ - direction_types = [e.value for e in LayoutDirectionEnum] - if direction not in direction_types: - direction = LayoutDirectionEnum.HORIZONTAL.value - self._model.layout_direction = direction - for node in self.all_nodes(): - node.set_layout_direction(direction) - self._viewer.set_layout_direction(direction) - - def fit_to_selection(self): - """ - Sets the zoom level to fit selected nodes. - If no nodes are selected then all nodes in the graph will be framed. - """ - nodes = self.selected_nodes() or self.all_nodes() - if not nodes: - return - self._viewer.zoom_to_nodes([n.view for n in nodes]) - - def reset_zoom(self): - """ - Reset the zoom level - """ - self._viewer.reset_zoom() - - def set_zoom(self, zoom=0): - """ - Set the zoom factor of the Node Graph the default is ``0.0`` - - Args: - zoom (float): zoom factor (max zoom out ``-0.9`` / max zoom in ``2.0``) - """ - self._viewer.set_zoom(zoom) - - def get_zoom(self): - """ - Get the current zoom level of the node graph. - - Returns: - float: the current zoom level. - """ - return self._viewer.get_zoom() - - def center_on(self, nodes=None): - """ - Center the node graph on the given nodes or all nodes by default. - - Args: - nodes (list[NodeGraphQt.BaseNode]): a list of nodes. - """ - nodes = nodes or [] - self._viewer.center_selection([n.view for n in nodes]) - - def center_selection(self): - """ - Centers on the current selected nodes. - """ - nodes = self._viewer.selected_nodes() - self._viewer.center_selection(nodes) - - def registered_nodes(self): - """ - Return a list of all node types that have been registered. - - See Also: - To register a node :meth:`NodeGraph.register_node` - - Returns: - list[str]: list of node type identifiers. - """ - return sorted(self._node_factory.nodes.keys()) - - def register_node(self, node, alias=None): - """ - Register the node to the :meth:`NodeGraph.node_factory` - - Args: - node (NodeGraphQt.NodeObject): node object. - alias (str): custom alias name for the node type. - """ - self._node_factory.register_node(node, alias) - self._viewer.rebuild_tab_search() - self.nodes_registered.emit([node]) - - def register_nodes(self, nodes): - """ - Register the nodes to the :meth:`NodeGraph.node_factory` - - Args: - nodes (list): list of nodes. - """ - [self._node_factory.register_node(n) for n in nodes] - self._viewer.rebuild_tab_search() - self.nodes_registered.emit(nodes) - - def create_node(self, node_type, name=None, selected=True, color=None, - text_color=None, pos=None, push_undo=True): - """ - Create a new node in the node graph. - - See Also: - To list all node types :meth:`NodeGraph.registered_nodes` - - Args: - node_type (str): node instance type. - name (str): set name of the node. - selected (bool): set created node to be selected. - color (tuple or str): node color ``(255, 255, 255)`` or ``"#FFFFFF"``. - text_color (tuple or str): text color ``(255, 255, 255)`` or ``"#FFFFFF"``. - pos (list[int, int]): initial x, y position for the node (default: ``(0, 0)``). - push_undo (bool): register the command to the undo stack. (default: True) - - Returns: - BaseNode: the created instance of the node. - """ - node = self._node_factory.create_node_instance(node_type) - if node: - node._graph = self - node.model._graph_model = self.model - - wid_types = node.model.__dict__.pop('_TEMP_property_widget_types') - prop_attrs = node.model.__dict__.pop('_TEMP_property_attrs') - - if self.model.get_node_common_properties(node.type_) is None: - node_attrs = {node.type_: { - n: {'widget_type': wt} for n, wt in wid_types.items() - }} - for pname, pattrs in prop_attrs.items(): - node_attrs[node.type_][pname].update(pattrs) - self.model.set_node_common_properties(node_attrs) - - accept_types = node.model.__dict__.pop( - '_TEMP_accept_connection_types' - ) - for ptype, pdata in accept_types.get(node.type_, {}).items(): - for pname, accept_data in pdata.items(): - for accept_ntype, accept_ndata in accept_data.items(): - for accept_ptype, accept_pnames in accept_ndata.items(): - for accept_pname in accept_pnames: - self._model.add_port_accept_connection_type( - port_name=pname, - port_type=ptype, - node_type=node.type_, - accept_pname=accept_pname, - accept_ptype=accept_ptype, - accept_ntype=accept_ntype - ) - reject_types = node.model.__dict__.pop( - '_TEMP_reject_connection_types' - ) - for ptype, pdata in reject_types.get(node.type_, {}).items(): - for pname, reject_data in pdata.items(): - for reject_ntype, reject_ndata in reject_data.items(): - for reject_ptype, reject_pnames in reject_ndata.items(): - for reject_pname in reject_pnames: - self._model.add_port_reject_connection_type( - port_name=pname, - port_type=ptype, - node_type=node.type_, - reject_pname=reject_pname, - reject_ptype=reject_ptype, - reject_ntype=reject_ntype - ) - - node.NODE_NAME = self.get_unique_name(name or node.NODE_NAME) - node.model.name = node.NODE_NAME - node.model.selected = selected - - def format_color(clr): - if isinstance(clr, str): - clr = clr.strip('#') - return tuple(int(clr[i:i + 2], 16) for i in (0, 2, 4)) - return clr - - if color: - node.model.color = format_color(color) - if text_color: - node.model.text_color = format_color(text_color) - if pos: - node.model.pos = [float(pos[0]), float(pos[1])] - - # initial node direction layout. - node.model.layout_direction = self.layout_direction() - - node.update() - - undo_cmd = NodeAddedCmd( - self, node, pos=node.model.pos, emit_signal=True - ) - if push_undo: - undo_label = 'create node: "{}"'.format(node.NODE_NAME) - self._undo_stack.beginMacro(undo_label) - for n in self.selected_nodes(): - n.set_property('selected', False, push_undo=True) - self._undo_stack.push(undo_cmd) - self._undo_stack.endMacro() - else: - for n in self.selected_nodes(): - n.set_property('selected', False, push_undo=False) - undo_cmd.redo() - - return node - - raise NodeCreationError('Can\'t find node: "{}"'.format(node_type)) - - def add_node(self, node, pos=None, selected=True, push_undo=True): - """ - Add a node into the node graph. - unlike the :meth:`NodeGraph.create_node` function this will not - trigger the :attr:`NodeGraph.node_created` signal. - - Args: - node (NodeGraphQt.BaseNode): node object. - pos (list[float]): node x,y position. (optional) - selected (bool): node selected state. (optional) - push_undo (bool): register the command to the undo stack. (default: True) - """ - assert isinstance(node, NodeObject), 'node must be a Node instance.' - - wid_types = node.model.__dict__.pop('_TEMP_property_widget_types') - prop_attrs = node.model.__dict__.pop('_TEMP_property_attrs') - - if self.model.get_node_common_properties(node.type_) is None: - node_attrs = {node.type_: { - n: {'widget_type': wt} for n, wt in wid_types.items() - }} - for pname, pattrs in prop_attrs.items(): - node_attrs[node.type_][pname].update(pattrs) - self.model.set_node_common_properties(node_attrs) - - accept_types = node.model.__dict__.pop( - '_TEMP_accept_connection_types' - ) - for ptype, pdata in accept_types.get(node.type_, {}).items(): - for pname, accept_data in pdata.items(): - for accept_ntype, accept_ndata in accept_data.items(): - for accept_ptype, accept_pnames in accept_ndata.items(): - for accept_pname in accept_pnames: - self._model.add_port_accept_connection_type( - port_name=pname, - port_type=ptype, - node_type=node.type_, - accept_pname=accept_pname, - accept_ptype=accept_ptype, - accept_ntype=accept_ntype - ) - reject_types = node.model.__dict__.pop( - '_TEMP_reject_connection_types' - ) - for ptype, pdata in reject_types.get(node.type_, {}).items(): - for pname, reject_data in pdata.items(): - for reject_ntype, reject_ndata in reject_data.items(): - for reject_ptype, reject_pnames in reject_ndata.items(): - for reject_pname in reject_pnames: - self._model.add_port_reject_connection_type( - port_name=pname, - port_type=ptype, - node_type=node.type_, - reject_pname=reject_pname, - reject_ptype=reject_ptype, - reject_ntype=reject_ntype - ) - - node._graph = self - node.NODE_NAME = self.get_unique_name(node.NODE_NAME) - node.model._graph_model = self.model - node.model.name = node.NODE_NAME - - # initial node direction layout. - node.model.layout_direction = self.layout_direction() - - # update method must be called before it's been added to the viewer. - node.update() - - undo_cmd = NodeAddedCmd(self, node, pos=pos, emit_signal=False) - if push_undo: - self._undo_stack.beginMacro('add node: "{}"'.format(node.name())) - self._undo_stack.push(undo_cmd) - if selected: - node.set_selected(True) - self._undo_stack.endMacro() - else: - undo_cmd.redo() - - def delete_node(self, node, push_undo=True): - """ - Remove the node from the node graph. - - Args: - node (NodeGraphQt.BaseNode): node object. - push_undo (bool): register the command to the undo stack. (default: True) - """ - assert isinstance(node, NodeObject), \ - 'node must be a instance of a NodeObject.' - node_id = node.id - if push_undo: - self._undo_stack.beginMacro('delete node: "{}"'.format(node.name())) - - if isinstance(node, BaseNode): - for p in node.input_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=push_undo) - p.clear_connections(push_undo=push_undo) - for p in node.output_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=push_undo) - p.clear_connections(push_undo=push_undo) - - # collapse group node before removing. - if isinstance(node, GroupNode) and node.is_expanded: - node.collapse() - - undo_cmd = NodesRemovedCmd(self, [node], emit_signal=True) - if push_undo: - self._undo_stack.push(undo_cmd) - self._undo_stack.endMacro() - else: - undo_cmd.redo() - - def remove_node(self, node, push_undo=True): - """ - Remove the node from the node graph. - - unlike the :meth:`NodeGraph.delete_node` function this will not - trigger the :attr:`NodeGraph.nodes_deleted` signal. - - Args: - node (NodeGraphQt.BaseNode): node object. - push_undo (bool): register the command to the undo stack. (default: True) - - """ - assert isinstance(node, NodeObject), 'node must be a Node instance.' - - if push_undo: - self._undo_stack.beginMacro('delete node: "{}"'.format(node.name())) - - # collapse group node before removing. - if isinstance(node, GroupNode) and node.is_expanded: - node.collapse() - - if isinstance(node, BaseNode): - for p in node.input_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=push_undo) - p.clear_connections(push_undo=push_undo) - for p in node.output_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=push_undo) - p.clear_connections(push_undo=push_undo) - - undo_cmd = NodesRemovedCmd(self, [node], emit_signal=False) - if push_undo: - self._undo_stack.push(undo_cmd) - self._undo_stack.endMacro() - else: - undo_cmd.redo() - - def delete_nodes(self, nodes, push_undo=True): - """ - Remove a list of specified nodes from the node graph. - - Args: - nodes (list[NodeGraphQt.BaseNode]): list of node instances. - push_undo (bool): register the command to the undo stack. (default: True) - """ - if not nodes: - return - if len(nodes) == 1: - self.delete_node(nodes[0], push_undo=push_undo) - return - node_ids = [n.id for n in nodes] - if push_undo: - self._undo_stack.beginMacro( - 'deleted "{}" node(s)'.format(len(nodes)) - ) - for node in nodes: - - # collapse group node before removing. - if isinstance(node, GroupNode) and node.is_expanded: - node.collapse() - - if isinstance(node, BaseNode): - for p in node.input_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=push_undo) - p.clear_connections(push_undo=push_undo) - for p in node.output_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=push_undo) - p.clear_connections(push_undo=push_undo) - - undo_cmd = NodesRemovedCmd(self, nodes, emit_signal=True) - if push_undo: - self._undo_stack.push(undo_cmd) - self._undo_stack.endMacro() - else: - undo_cmd.redo() - - self.nodes_deleted.emit(node_ids) - - def extract_nodes(self, nodes, push_undo=True, prompt_warning=True): - """ - Extract select nodes from its connections. - - Args: - nodes (list[NodeGraphQt.BaseNode]): list of node instances. - push_undo (bool): register the command to the undo stack. (default: True) - prompt_warning (bool): prompt warning dialog box. - """ - if not nodes: - return - - locked_ports = [] - base_nodes = [] - for node in nodes: - if not isinstance(node, BaseNode): - continue - - for port in node.input_ports() + node.output_ports(): - if port.locked(): - locked_ports.append('{0.node.name}: {0.name}'.format(port)) - - base_nodes.append(node) - - if locked_ports: - message = ( - 'Selected nodes cannot be extracted because the following ' - 'ports are locked:\n{}'.format('\n'.join(sorted(locked_ports))) - ) - if prompt_warning: - self._viewer.message_dialog(message, 'Can\'t Extract Nodes') - return - - if push_undo: - self._undo_stack.beginMacro( - 'extracted "{}" node(s)'.format(len(nodes)) - ) - - for node in base_nodes: - for port in node.input_ports() + node.output_ports(): - for connected_port in port.connected_ports(): - if connected_port.node() in base_nodes: - continue - port.disconnect_from(connected_port, push_undo=push_undo) - - if push_undo: - self._undo_stack.endMacro() - - def all_nodes(self): - """ - Return all nodes in the node graph. - - Returns: - list[NodeGraphQt.BaseNode]: list of nodes. - """ - return list(self._model.nodes.values()) - - def selected_nodes(self): - """ - Return all selected nodes that are in the node graph. - - Returns: - list[NodeGraphQt.BaseNode]: list of nodes. - """ - nodes = [] - for item in self._viewer.selected_nodes(): - node = self._model.nodes[item.id] - nodes.append(node) - return nodes - - def select_all(self): - """ - Select all nodes in the node graph. - """ - self._undo_stack.beginMacro('select all') - [node.set_selected(True) for node in self.all_nodes()] - self._undo_stack.endMacro() - - def clear_selection(self): - """ - Clears the selection in the node graph. - """ - self._undo_stack.beginMacro('clear selection') - [node.set_selected(False) for node in self.all_nodes()] - self._undo_stack.endMacro() - - def invert_selection(self): - """ - Inverts the current node selection. - """ - if not self.selected_nodes(): - self.select_all() - return - self._undo_stack.beginMacro('invert selection') - for node in self.all_nodes(): - node.set_selected(not node.selected()) - self._undo_stack.endMacro() - - def get_node_by_id(self, node_id=None): - """ - Returns the node from the node id string. - - Args: - node_id (str): node id (:attr:`NodeObject.id`) - - Returns: - NodeGraphQt.NodeObject: node object. - """ - return self._model.nodes.get(node_id, None) - - def get_node_by_name(self, name): - """ - Returns node that matches the name. - - Args: - name (str): name of the node. - Returns: - NodeGraphQt.NodeObject: node object. - """ - for node_id, node in self._model.nodes.items(): - if node.name() == name: - return node - - def get_nodes_by_type(self, node_type): - """ - Return all nodes by their node type identifier. - (see: :attr:`NodeGraphQt.NodeObject.type_`) - - Args: - node_type (str): node type identifier. - - Returns: - list[NodeGraphQt.NodeObject]: list of nodes. - """ - return [n for n in self._model.nodes.values() if n.type_ == node_type] - - def get_unique_name(self, name): - """ - Creates a unique node name to avoid having nodes with the same name. - - Args: - name (str): node name. - - Returns: - str: unique node name. - """ - name = ' '.join(name.split()) - node_names = [n.name() for n in self.all_nodes()] - if name not in node_names: - return name - - regex = re.compile(r'\w+ (\d+)$') - search = regex.search(name) - if not search: - for x in range(1, len(node_names) + 2): - new_name = '{} {}'.format(name, x) - if new_name not in node_names: - return new_name - - version = search.group(1) - name = name[:len(version) * -1].strip() - for x in range(1, len(node_names) + 2): - new_name = '{} {}'.format(name, x) - if new_name not in node_names: - return new_name - - def current_session(self): - """ - Returns the file path to the currently loaded session. - - Returns: - str: path to the currently loaded session - """ - return self._model.session - - def clear_session(self): - """ - Clears the current node graph session. - """ - nodes = self.all_nodes() - for n in nodes: - if isinstance(n, BaseNode): - for p in n.input_ports(): - if p.locked(): - p.set_locked(False, connected_ports=False) - p.clear_connections() - for p in n.output_ports(): - if p.locked(): - p.set_locked(False, connected_ports=False) - p.clear_connections() - self._undo_stack.push(NodesRemovedCmd(self, nodes)) - self._undo_stack.clear() - self._model.session = '' - - def _serialize(self, nodes): - """ - serialize nodes to a dict. - (used internally by the node graph) - - Args: - nodes (list[NodeGraphQt.Nodes]): list of node instances. - - Returns: - dict: serialized data. - """ - serial_data = {'graph': {}, 'nodes': {}, 'connections': []} - nodes_data = {} - - # serialize graph session. - serial_data['graph']['layout_direction'] = self.layout_direction() - serial_data['graph']['acyclic'] = self.acyclic() - serial_data['graph']['pipe_collision'] = self.pipe_collision() - serial_data['graph']['pipe_slicing'] = self.pipe_slicing() - serial_data['graph']['pipe_style'] = self.pipe_style() - - # connection constrains. - serial_data['graph']['accept_connection_types'] = self.model.accept_connection_types - serial_data['graph']['reject_connection_types'] = self.model.reject_connection_types - - # serialize nodes. - for n in nodes: - # update the node model. - n.update_model() - - node_dict = n.model.to_dict - nodes_data.update(node_dict) - - for n_id, n_data in nodes_data.items(): - serial_data['nodes'][n_id] = n_data - - # serialize connections - inputs = n_data.pop('inputs') if n_data.get('inputs') else {} - outputs = n_data.pop('outputs') if n_data.get('outputs') else {} - - for pname, conn_data in inputs.items(): - for conn_id, prt_names in conn_data.items(): - for conn_prt in prt_names: - pipe = { - PortTypeEnum.IN.value: [n_id, pname], - PortTypeEnum.OUT.value: [conn_id, conn_prt] - } - if pipe not in serial_data['connections']: - serial_data['connections'].append(pipe) - - for pname, conn_data in outputs.items(): - for conn_id, prt_names in conn_data.items(): - for conn_prt in prt_names: - pipe = { - PortTypeEnum.OUT.value: [n_id, pname], - PortTypeEnum.IN.value: [conn_id, conn_prt] - } - if pipe not in serial_data['connections']: - serial_data['connections'].append(pipe) - - if not serial_data['connections']: - serial_data.pop('connections') - - return serial_data - - def _deserialize(self, data, relative_pos=False, pos=None): - """ - deserialize node data. - (used internally by the node graph) - - Args: - data (dict): node data. - relative_pos (bool): position node relative to the cursor. - pos (tuple or list): custom x, y position. - - Returns: - list[NodeGraphQt.Nodes]: list of node instances. - """ - # update node graph properties. - for attr_name, attr_value in data.get('graph', {}).items(): - if attr_name == 'layout_direction': - self.set_layout_direction(attr_value) - elif attr_name == 'acyclic': - self.set_acyclic(attr_value) - elif attr_name == 'pipe_collision': - self.set_pipe_collision(attr_value) - elif attr_name == 'pipe_slicing': - self.set_pipe_slicing(attr_value) - elif attr_name == 'pipe_style': - self.set_pipe_style(attr_value) - - # connection constrains. - elif attr_name == 'accept_connection_types': - self.model.accept_connection_types = attr_value - elif attr_name == 'reject_connection_types': - self.model.reject_connection_types = attr_value - - # build the nodes. - nodes = {} - for n_id, n_data in data.get('nodes', {}).items(): - identifier = n_data['type_'] - node = self._node_factory.create_node_instance(identifier) - if node: - node.NODE_NAME = n_data.get('name', node.NODE_NAME) - # set properties. - for prop in node.model.properties.keys(): - if prop in n_data.keys(): - node.model.set_property(prop, n_data[prop]) - # set custom properties. - for prop, val in n_data.get('custom', {}).items(): - node.model.set_property(prop, val) - if isinstance(node, BaseNode): - if prop in node.view.widgets: - node.view.widgets[prop].set_value(val) - - nodes[n_id] = node - self.add_node(node, n_data.get('pos')) - - if n_data.get('port_deletion_allowed', None): - node.set_ports({ - 'input_ports': n_data['input_ports'], - 'output_ports': n_data['output_ports'] - }) - - # build the connections. - for connection in data.get('connections', []): - nid, pname = connection.get('in', ('', '')) - in_node = nodes.get(nid) or self.get_node_by_id(nid) - if not in_node: - continue - in_port = in_node.inputs().get(pname) if in_node else None - - nid, pname = connection.get('out', ('', '')) - out_node = nodes.get(nid) or self.get_node_by_id(nid) - if not out_node: - continue - out_port = out_node.outputs().get(pname) if out_node else None - - if in_port and out_port: - # only connect if input port is not connected yet or input port - # can have multiple connections. - # important when duplicating nodes. - allow_connection = any([not in_port.model.connected_ports, - in_port.model.multi_connection]) - if allow_connection: - self._undo_stack.push( - PortConnectedCmd(in_port, out_port, emit_signal=False) - ) - - # Run on_input_connected to ensure connections are fully set up - # after deserialization. - in_node.on_input_connected(in_port, out_port) - - node_objs = nodes.values() - if relative_pos: - self._viewer.move_nodes([n.view for n in node_objs]) - [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] - elif pos: - self._viewer.move_nodes([n.view for n in node_objs], pos=pos) - [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] - - return node_objs - - def serialize_session(self): - """ - Serializes the current node graph layout to a dictionary. - - See Also: - :meth:`NodeGraph.deserialize_session`, - :meth:`NodeGraph.save_session`, - :meth:`NodeGraph.load_session` - - Returns: - dict: serialized session of the current node layout. - """ - return self._serialize(self.all_nodes()) - - def deserialize_session(self, layout_data, clear_session=True, - clear_undo_stack=True): - """ - Load node graph session from a dictionary object. - - See Also: - :meth:`NodeGraph.serialize_session`, - :meth:`NodeGraph.load_session`, - :meth:`NodeGraph.save_session` - - Args: - layout_data (dict): dictionary object containing a node session. - clear_session (bool): clear current session. - clear_undo_stack (bool): clear the undo stack. - """ - if clear_session: - self.clear_session() - self._deserialize(layout_data) - self.clear_selection() - if clear_undo_stack: - self._undo_stack.clear() - - def save_session(self, file_path): - """ - Saves the current node graph session layout to a `JSON` formatted file. - - See Also: - :meth:`NodeGraph.serialize_session`, - :meth:`NodeGraph.deserialize_session`, - :meth:`NodeGraph.load_session`, - - Args: - file_path (str): path to the saved node layout. - """ - serialized_data = self.serialize_session() - file_path = file_path.strip() - - def default(obj): - if isinstance(obj, set): - return list(obj) - return obj - - with open(file_path, 'w') as file_out: - json.dump( - serialized_data, - file_out, - indent=2, - separators=(',', ':'), - default=default - ) - - # update the current session. - self._model.session = file_path - - def load_session(self, file_path): - """ - Load node graph session layout file. - - See Also: - :meth:`NodeGraph.deserialize_session`, - :meth:`NodeGraph.serialize_session`, - :meth:`NodeGraph.save_session` - - Args: - file_path (str): path to the serialized layout file. - """ - file_path = file_path.strip() - if not os.path.isfile(file_path): - raise IOError('file does not exist: {}'.format(file_path)) - - self.clear_session() - self.import_session(file_path, clear_undo_stack=True) - - def import_session(self, file_path, clear_undo_stack=True): - """ - Import node graph into the current session. - - Args: - file_path (str): path to the serialized layout file. - clear_undo_stack (bool): clear the undo stack after import. - """ - file_path = file_path.strip() - if not os.path.isfile(file_path): - raise IOError('file does not exist: {}'.format(file_path)) - - try: - with open(file_path) as data_file: - layout_data = json.load(data_file) - except Exception as e: - layout_data = None - print('Cannot read data from file.\n{}'.format(e)) - - if not layout_data: - return - - self.deserialize_session( - layout_data, - clear_session=False, - clear_undo_stack=clear_undo_stack - ) - self._model.session = file_path - - self.session_changed.emit(file_path) - - def copy_nodes(self, nodes=None): - """ - Copy nodes to the clipboard as a JSON formatted ``str``. - - See Also: - :meth:`NodeGraph.cut_nodes` - - Args: - nodes (list[NodeGraphQt.BaseNode]): - list of nodes (default: selected nodes). - """ - nodes = nodes or self.selected_nodes() - if not nodes: - return False - clipboard = QtWidgets.QApplication.clipboard() - serial_data = self._serialize(nodes) - serial_str = json.dumps(serial_data) - if serial_str: - clipboard.setText(serial_str) - return True - return False - - def cut_nodes(self, nodes=None): - """ - Cut nodes to the clipboard as a JSON formatted ``str``. - - Note: - This function doesn't trigger the - :attr:`NodeGraph.nodes_deleted` signal. - - See Also: - :meth:`NodeGraph.copy_nodes` - - Args: - nodes (list[NodeGraphQt.BaseNode]): - list of nodes (default: selected nodes). - """ - nodes = nodes or self.selected_nodes() - self.copy_nodes(nodes) - self._undo_stack.beginMacro('cut nodes') - - for node in nodes: - if isinstance(node, BaseNode): - for p in node.input_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=True) - p.clear_connections() - for p in node.output_ports(): - if p.locked(): - p.set_locked(False, - connected_ports=False, - push_undo=True) - p.clear_connections() - - # collapse group node before removing. - if isinstance(node, GroupNode) and node.is_expanded: - node.collapse() - - self._undo_stack.push(NodesRemovedCmd(self, nodes)) - self._undo_stack.endMacro() - - def paste_nodes(self): - """ - Pastes nodes copied from the clipboard. - - Returns: - list[NodeGraphQt.BaseNode]: list of pasted node instances. - """ - clipboard = QtWidgets.QApplication.clipboard() - cb_text = clipboard.text() - if not cb_text: - return - - try: - serial_data = json.loads(cb_text) - except json.decoder.JSONDecodeError as e: - print('ERROR: Can\'t Decode Clipboard Data:\n' - '"{}"'.format(cb_text)) - return - - self._undo_stack.beginMacro('pasted nodes') - self.clear_selection() - nodes = self._deserialize(serial_data, relative_pos=True) - [n.set_selected(True) for n in nodes] - self._undo_stack.endMacro() - return nodes - - def duplicate_nodes(self, nodes): - """ - Create duplicate copy from the list of nodes. - - Args: - nodes (list[NodeGraphQt.BaseNode]): list of nodes. - Returns: - list[NodeGraphQt.BaseNode]: list of duplicated node instances. - """ - if not nodes: - return - - self._undo_stack.beginMacro('duplicate nodes') - - self.clear_selection() - serial = self._serialize(nodes) - new_nodes = self._deserialize(serial) - offset = 50 - for n in new_nodes: - x, y = n.pos() - n.set_pos(x + offset, y + offset) - n.set_property('selected', True) - - self._undo_stack.endMacro() - return new_nodes - - def disable_nodes(self, nodes, mode=None): - """ - Toggle nodes to be either disabled or enabled state. - - See Also: - :meth:`NodeObject.set_disabled` - - Args: - nodes (list[NodeGraphQt.BaseNode]): list of nodes. - mode (bool): (optional) override state of the nodes. - """ - if not nodes: - return - - if len(nodes) == 1: - if mode is None: - mode = not nodes[0].disabled() - nodes[0].set_disabled(mode) - return - - if mode is not None: - states = {False: 'enable', True: 'disable'} - text = '{} ({}) nodes'.format(states[mode], len(nodes)) - self._undo_stack.beginMacro(text) - [n.set_disabled(mode) for n in nodes] - self._undo_stack.endMacro() - return - - text = [] - enabled_count = len([n for n in nodes if n.disabled()]) - disabled_count = len([n for n in nodes if not n.disabled()]) - if enabled_count > 0: - text.append('enabled ({})'.format(enabled_count)) - if disabled_count > 0: - text.append('disabled ({})'.format(disabled_count)) - text = ' / '.join(text) + ' nodes' - - self._undo_stack.beginMacro(text) - [n.set_disabled(not n.disabled()) for n in nodes] - self._undo_stack.endMacro() - - def use_OpenGL(self): - """ - Set the viewport to use QOpenGLWidget widget to draw the graph. - """ - self._viewer.use_OpenGL() - - # auto layout node functions. - # -------------------------------------------------------------------------- - - @staticmethod - def _update_node_rank(node, nodes_rank, down_stream=True): - """ - Recursive function for updating the node ranking. - - Args: - node (NodeGraphQt.BaseNode): node to start from. - nodes_rank (dict): node ranking object to be updated. - down_stream (bool): true to rank down stram. - """ - if down_stream: - node_values = node.connected_output_nodes().values() - else: - node_values = node.connected_input_nodes().values() - - connected_nodes = set() - for nodes in node_values: - connected_nodes.update(nodes) - - rank = nodes_rank[node] + 1 - for n in connected_nodes: - if n in nodes_rank: - nodes_rank[n] = max(nodes_rank[n], rank) - else: - nodes_rank[n] = rank - NodeGraph._update_node_rank(n, nodes_rank, down_stream) - - @staticmethod - def _compute_node_rank(nodes, down_stream=True): - """ - Compute the ranking of nodes. - - Args: - nodes (list[NodeGraphQt.BaseNode]): nodes to start ranking from. - down_stream (bool): true to compute down stream. - - Returns: - dict: {NodeGraphQt.BaseNode: node_rank, ...} - """ - nodes_rank = {} - for node in nodes: - nodes_rank[node] = 0 - NodeGraph._update_node_rank(node, nodes_rank, down_stream) - return nodes_rank - - def auto_layout_nodes(self, nodes=None, down_stream=True, start_nodes=None): - """ - Auto layout the nodes in the node graph. - - Note: - If the node graph is acyclic then the ``start_nodes`` will need - to be specified. - - Args: - nodes (list[NodeGraphQt.BaseNode]): list of nodes to auto layout - if nodes is None then all nodes is layed out. - down_stream (bool): false to layout up stream. - start_nodes (list[NodeGraphQt.BaseNode]): - list of nodes to start the auto layout from (Optional). - """ - self.begin_undo('Auto Layout Nodes') - - nodes = nodes or self.all_nodes() - - # filter out the backdrops. - backdrops = { - n: n.nodes() for n in nodes if isinstance(n, BackdropNode) - } - filtered_nodes = [n for n in nodes if not isinstance(n, BackdropNode)] - - start_nodes = start_nodes or [] - if down_stream: - start_nodes += [ - n for n in filtered_nodes - if not any(n.connected_input_nodes().values()) - ] - else: - start_nodes += [ - n for n in filtered_nodes - if not any(n.connected_output_nodes().values()) - ] - - if not start_nodes: - return - - node_views = [n.view for n in nodes] - nodes_center_0 = self.viewer().nodes_rect_center(node_views) - - nodes_rank = NodeGraph._compute_node_rank(start_nodes, down_stream) - - rank_map = {} - for node, rank in nodes_rank.items(): - if rank in rank_map: - rank_map[rank].append(node) - else: - rank_map[rank] = [node] - - node_layout_direction = self._viewer.get_layout_direction() - - if node_layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - current_x = 0 - node_height = 120 - for rank in sorted(range(len(rank_map)), reverse=not down_stream): - ranked_nodes = rank_map[rank] - max_width = max([node.view.width for node in ranked_nodes]) - current_x += max_width - current_y = 0 - for idx, node in enumerate(ranked_nodes): - dy = max(node_height, node.view.height) - current_y += 0 if idx == 0 else dy - node.set_pos(current_x, current_y) - current_y += dy * 0.5 + 10 - - current_x += max_width * 0.5 + 100 - elif node_layout_direction is LayoutDirectionEnum.VERTICAL.value: - current_y = 0 - node_width = 250 - for rank in sorted(range(len(rank_map)), reverse=not down_stream): - ranked_nodes = rank_map[rank] - max_height = max([node.view.height for node in ranked_nodes]) - current_y += max_height - current_x = 0 - for idx, node in enumerate(ranked_nodes): - dx = max(node_width, node.view.width) - current_x += 0 if idx == 0 else dx - node.set_pos(current_x, current_y) - current_x += dx * 0.5 + 10 - - current_y += max_height * 0.5 + 100 - - nodes_center_1 = self.viewer().nodes_rect_center(node_views) - dx = nodes_center_0[0] - nodes_center_1[0] - dy = nodes_center_0[1] - nodes_center_1[1] - [n.set_pos(n.x_pos() + dx, n.y_pos() + dy) for n in nodes] - - # wrap the backdrop nodes. - for backdrop, contained_nodes in backdrops.items(): - backdrop.wrap_nodes(contained_nodes) - - self.end_undo() - - # convenience dialog functions. - # -------------------------------------------------------------------------- - - def question_dialog(self, text, title='Node Graph', dialog_icon=None, - custom_icon=None, parent=None): - """ - Prompts a question open dialog with ``"Yes"`` and ``"No"`` buttons in - the node graph. - - Note: - Convenience function to - :meth:`NodeGraph.viewer().question_dialog` - - Args: - text (str): question text. - title (str): dialog window title. - dialog_icon (str): display icon. ("information", "warning", "critical") - custom_icon (str): custom icon to display. - parent (QtWidgets.QObject): override dialog parent. (optional) - - Returns: - bool: true if user clicked yes. - """ - return self._viewer.question_dialog( - text, title, dialog_icon, custom_icon, parent - ) - - def message_dialog(self, text, title='Node Graph', dialog_icon=None, - custom_icon=None, parent=None): - """ - Prompts a file open dialog in the node graph. - - Note: - Convenience function to - :meth:`NodeGraph.viewer().message_dialog` - - Args: - text (str): message text. - title (str): dialog window title. - dialog_icon (str): display icon. ("information", "warning", "critical") - custom_icon (str): custom icon to display. - parent (QtWidgets.QObject): override dialog parent. (optional) - """ - self._viewer.message_dialog( - text, title, dialog_icon, custom_icon, parent - ) - - def load_dialog(self, current_dir=None, ext=None, parent=None): - """ - Prompts a file open dialog in the node graph. - - Note: - Convenience function to - :meth:`NodeGraph.viewer().load_dialog` - - Args: - current_dir (str): path to a directory. - ext (str): custom file type extension (default: ``"json"``) - parent (QtWidgets.QObject): override dialog parent. (optional) - - Returns: - str: selected file path. - """ - return self._viewer.load_dialog(current_dir, ext, parent) - - def save_dialog(self, current_dir=None, ext=None, parent=None): - """ - Prompts a file save dialog in the node graph. - - Note: - Convenience function to - :meth:`NodeGraph.viewer().save_dialog` - - Args: - current_dir (str): path to a directory. - ext (str): custom file type extension (default: ``"json"``) - parent (QtWidgets.QObject): override dialog parent. (optional) - - Returns: - str: selected file path. - """ - return self._viewer.save_dialog(current_dir, ext, parent) - - # group node / sub graph. - # -------------------------------------------------------------------------- - - def _on_close_sub_graph_tab(self, index): - """ - Called when the close button is clicked on a expanded sub graph tab. - - Args: - index (int): tab index. - """ - node_id = self.widget.tabToolTip(index) - group_node = self.get_node_by_id(node_id) - self.collapse_group_node(group_node) - - @property - def is_root(self): - """ - Returns if the node graph controller is the root graph. - - Returns: - bool: true is the node graph is root. - """ - return True - - @property - def sub_graphs(self): - """ - Returns expanded group node sub graphs. - - Returns: - dict: {: } - """ - return self._sub_graphs - - # def graph_rect(self): - # """ - # Get the graph viewer range (scene size). - # - # Returns: - # list[float]: [x, y, width, height]. - # """ - # return self._viewer.scene_rect() - # - # def set_graph_rect(self, rect): - # """ - # Set the graph viewer range (scene size). - # - # Args: - # rect (list[float]): [x, y, width, height]. - # """ - # self._viewer.set_scene_rect(rect) - - def expand_group_node(self, node): - """ - Expands a group node session in a new tab. - - Args: - node (NodeGraphQt.GroupNode): group node. - - Returns: - SubGraph: sub node graph used to manage the group node session. - """ - if not isinstance(node, GroupNode): - return - if self._widget is None: - raise RuntimeError('NodeGraph.widget not initialized!') - - self.viewer().clear_key_state() - self.viewer().clearFocus() - - if node.id in self._sub_graphs: - sub_graph = self._sub_graphs[node.id] - tab_index = self._widget.indexOf(sub_graph.widget) - self._widget.setCurrentIndex(tab_index) - return sub_graph - - # build new sub graph. - node_factory = copy.deepcopy(self.node_factory) - layout_direction = self.layout_direction() - kwargs = { - 'layout_direction': self.layout_direction(), - 'pipe_style': self.pipe_style(), - } - sub_graph = SubGraph(self, - node=node, - node_factory=node_factory, - **kwargs) - - # populate the sub graph. - session = node.get_sub_graph_session() - sub_graph.deserialize_session(session) - - # store reference to expanded. - self._sub_graphs[node.id] = sub_graph - - # open new tab at root level. - self.widget.add_viewer(sub_graph.widget, node.name(), node.id) - - return sub_graph - - def collapse_group_node(self, node): - """ - Collapse a group node session tab and it's expanded child sub graphs. - - Args: - node (NodeGraphQt.GroupNode): group node. - """ - assert isinstance(node, GroupNode), 'node must be a GroupNode instance.' - if self._widget is None: - return - - if node.id not in self._sub_graphs: - err = '{} sub graph not initialized!'.format(node.name()) - raise RuntimeError(err) - - sub_graph = self._sub_graphs.pop(node.id) - sub_graph.collapse_group_node(node) - - # remove the sub graph tab. - self.widget.remove_viewer(sub_graph.widget) - - # TODO: delete sub graph hmm... not sure if I need this here. - del sub_graph - - -class SubGraph(NodeGraph): - """ - The ``SubGraph`` class is just like the ``NodeGraph`` but is the main - controller for managing the expanded node graph for a - :class:`NodeGraphQt.GroupNode`. - - .. inheritance-diagram:: NodeGraphQt.SubGraph - :top-classes: PySide2.QtCore.QObject - - .. image:: ../_images/sub_graph.png - :width: 70% - - - - """ - - def __init__(self, parent=None, node=None, node_factory=None, **kwargs): - """ - Args: - parent (object): object parent. - node (GroupNode): group node related to this sub graph. - node_factory (NodeFactory): override node factory. - **kwargs (dict): additional kwargs. - """ - super(SubGraph, self).__init__( - parent, node_factory=node_factory, **kwargs - ) - - # sub graph attributes. - self._node = node - self._parent_graph = parent - self._subviewer_widget = None - - if self._parent_graph.is_root: - self._initialized_graphs = [self] - self._sub_graphs[self._node.id] = self - else: - # delete attributes if not top level sub graph. - del self._widget - del self._sub_graphs - - # clone context menu from the parent node graph. - self._clone_context_menu_from_parent() - - def __repr__(self): - return '<{}("{}") object at {}>'.format( - self.__class__.__name__, self._node.name(), hex(id(self))) - - def _register_builtin_nodes(self): - """ - Register the default builtin nodes to the :meth:`NodeGraph.node_factory` - """ - return - - def _clone_context_menu_from_parent(self): - """ - Clone the context menus from the parent node graph. - """ - graph_menu = self.get_context_menu('graph') - parent_menu = self.parent_graph.get_context_menu('graph') - parent_viewer = self.parent_graph.viewer() - excl_actions = [parent_viewer.qaction_for_undo(), - parent_viewer.qaction_for_redo()] - - def clone_menu(menu, menu_to_clone): - """ - Args: - menu (NodeGraphQt.NodeGraphMenu): - menu_to_clone (NodeGraphQt.NodeGraphMenu): - """ - sub_items = [] - for item in menu_to_clone.get_items(): - if item is None: - menu.add_separator() - continue - name = item.name() - if isinstance(item, NodeGraphMenu): - sub_menu = menu.add_menu(name) - sub_items.append([sub_menu, item]) - continue - - if item in excl_actions: - continue - - menu.add_command( - name, - func=item.slot_function, - shortcut=item.qaction.shortcut() - ) - - for sub_menu, to_clone in sub_items: - clone_menu(sub_menu, to_clone) - - # duplicate the menu items. - clone_menu(graph_menu, parent_menu) - - def _build_port_nodes(self): - """ - Build the corresponding input & output nodes from the parent node ports - and remove any port nodes that are outdated.. - - Returns: - tuple(dict, dict): input nodes, output nodes. - """ - node_layout_direction = self._viewer.get_layout_direction() - - # build the parent input port nodes. - input_nodes = {n.name(): n for n in - self.get_nodes_by_type(PortInputNode.type_)} - for port in self.node.input_ports(): - if port.name() not in input_nodes: - input_node = PortInputNode(parent_port=port) - input_node.NODE_NAME = port.name() - input_node.model.set_property('name', port.name()) - input_node.add_output(port.name()) - input_nodes[port.name()] = input_node - self.add_node(input_node, selected=False, push_undo=False) - x, y = input_node.pos() - if node_layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - x -= 100 - elif node_layout_direction is LayoutDirectionEnum.VERTICAL.value: - y -= 100 - input_node.set_property('pos', [x, y], push_undo=False) - - # build the parent output port nodes. - output_nodes = {n.name(): n for n in - self.get_nodes_by_type(PortOutputNode.type_)} - for port in self.node.output_ports(): - if port.name() not in output_nodes: - output_node = PortOutputNode(parent_port=port) - output_node.NODE_NAME = port.name() - output_node.model.set_property('name', port.name()) - output_node.add_input(port.name()) - output_nodes[port.name()] = output_node - self.add_node(output_node, selected=False, push_undo=False) - x, y = output_node.pos() - if node_layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - x += 100 - elif node_layout_direction is LayoutDirectionEnum.VERTICAL.value: - y += 100 - output_node.set_property('pos', [x, y], push_undo=False) - - return input_nodes, output_nodes - - def _deserialize(self, data, relative_pos=False, pos=None): - """ - deserialize node data. - (used internally by the node graph) - - Args: - data (dict): node data. - relative_pos (bool): position node relative to the cursor. - pos (tuple or list): custom x, y position. - - Returns: - list[NodeGraphQt.Nodes]: list of node instances. - """ - # update node graph properties. - for attr_name, attr_value in data.get('graph', {}).items(): - if attr_name == 'acyclic': - self.set_acyclic(attr_value) - elif attr_name == 'pipe_collision': - self.set_pipe_collision(attr_value) - - # build the port input & output nodes here. - input_nodes, output_nodes = self._build_port_nodes() - - # build the nodes. - nodes = {} - for n_id, n_data in data.get('nodes', {}).items(): - identifier = n_data['type_'] - name = n_data.get('name') - if identifier == PortInputNode.type_: - nodes[n_id] = input_nodes[name] - nodes[n_id].set_pos(*(n_data.get('pos') or [0, 0])) - continue - elif identifier == PortOutputNode.type_: - nodes[n_id] = output_nodes[name] - nodes[n_id].set_pos(*(n_data.get('pos') or [0, 0])) - continue - - node = self._node_factory.create_node_instance(identifier) - if not node: - continue - - node.NODE_NAME = name or node.NODE_NAME - # set properties. - for prop in node.model.properties.keys(): - if prop in n_data.keys(): - node.model.set_property(prop, n_data[prop]) - # set custom properties. - for prop, val in n_data.get('custom', {}).items(): - node.model.set_property(prop, val) - - nodes[n_id] = node - self.add_node(node, n_data.get('pos')) - - if n_data.get('port_deletion_allowed', None): - node.set_ports({ - 'input_ports': n_data['input_ports'], - 'output_ports': n_data['output_ports'] - }) - - # build the connections. - for connection in data.get('connections', []): - nid, pname = connection.get('in', ('', '')) - in_node = nodes.get(nid) - if not in_node: - continue - in_port = in_node.inputs().get(pname) if in_node else None - - nid, pname = connection.get('out', ('', '')) - out_node = nodes.get(nid) - if not out_node: - continue - out_port = out_node.outputs().get(pname) if out_node else None - - if in_port and out_port: - self._undo_stack.push( - PortConnectedCmd(in_port, out_port, emit_signal=False) - ) - - node_objs = list(nodes.values()) - if relative_pos: - self._viewer.move_nodes([n.view for n in node_objs]) - [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] - elif pos: - self._viewer.move_nodes([n.view for n in node_objs], pos=pos) - [setattr(n.model, 'pos', n.view.xy_pos) for n in node_objs] - - return node_objs - - def _on_navigation_changed(self, node_id, rm_node_ids): - """ - Slot when the node navigation widget has changed. - - Args: - node_id (str): selected group node id. - rm_node_ids (list[str]): list of group node id to remove. - """ - # collapse child sub graphs. - for rm_node_id in rm_node_ids: - child_node = self.sub_graphs[rm_node_id].node - self.collapse_group_node(child_node) - - # show the selected node id sub graph. - sub_graph = self.sub_graphs.get(node_id) - if sub_graph: - self.widget.show_viewer(sub_graph.subviewer_widget) - sub_graph.viewer().setFocus() - - @property - def is_root(self): - """ - Returns if the node graph controller is the main root graph. - - Returns: - bool: true is the node graph is root. - """ - return False - - @property - def sub_graphs(self): - """ - Returns expanded group node sub graphs. - - Returns: - dict: {: } - """ - if self.parent_graph.is_root: - return self._sub_graphs - return self.parent_graph.sub_graphs - - @property - def initialized_graphs(self): - """ - Returns a list of the sub graphs in the order they were initialized. - - Returns: - list[NodeGraphQt.SubGraph]: list of sub graph objects. - """ - if self._parent_graph.is_root: - return self._initialized_graphs - return self._parent_graph.initialized_graphs - - @property - def widget(self): - """ - The sub graph widget from the top most sub graph. - - Returns: - SubGraphWidget: node graph widget. - """ - if self.parent_graph.is_root: - if self._widget is None: - self._widget = SubGraphWidget() - self._widget.add_viewer(self.subviewer_widget, - self.node.name(), - self.node.id) - # connect the navigator widget signals. - navigator = self._widget.navigator - navigator.navigation_changed.connect( - self._on_navigation_changed - ) - return self._widget - return self.parent_graph.widget - - @property - def navigation_widget(self): - """ - The navigation widget from the top most sub graph. - - Returns: - NodeNavigationWidget: navigation widget. - """ - if self.parent_graph.is_root: - return self.widget.navigator - return self.parent_graph.navigation_widget - - @property - def subviewer_widget(self): - """ - The widget to the sub graph. - - Returns: - PySide2.QtWidgets.QWidget: node graph widget. - """ - if self._subviewer_widget is None: - self._subviewer_widget = QtWidgets.QWidget() - layout = QtWidgets.QVBoxLayout(self._subviewer_widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(1) - layout.addWidget(self._viewer) - return self._subviewer_widget - - @property - def parent_graph(self): - """ - The parent node graph controller. - - Returns: - NodeGraphQt.NodeGraph or NodeGraphQt.SubGraph: parent graph. - """ - return self._parent_graph - - @property - def node(self): - """ - Returns the parent node to the sub graph. - - .. image:: ../_images/group_node.png - :width: 250px - - Returns: - NodeGraphQt.GroupNode: group node. - """ - return self._node - - def delete_node(self, node, push_undo=True): - """ - Remove the node from the node sub graph. - - Note: - :class:`.PortInputNode` & :class:`.PortOutputNode` can't be deleted - as they are connected to a :class:`.Port` to remove these port nodes - see :meth:`BaseNode.delete_input`, :meth:`BaseNode.delete_output`. - - Args: - node (NodeGraphQt.BaseNode): node object. - push_undo (bool): register the command to the undo stack. (default: True) - """ - port_nodes = self.get_input_port_nodes() + self.get_output_port_nodes() - if node in port_nodes and node.parent_port is not None: - # note: port nodes can only be deleted by deleting the parent - # port object. - raise NodeDeletionError( - '{} can\'t be deleted as it is attached to a port!'.format(node) - ) - super(SubGraph, self).delete_node(node, push_undo=push_undo) - - def delete_nodes(self, nodes, push_undo=True): - """ - Remove a list of specified nodes from the node graph. - - Args: - nodes (list[NodeGraphQt.BaseNode]): list of node instances. - push_undo (bool): register the command to the undo stack. (default: True) - """ - if not nodes: - return - - port_nodes = self.get_input_port_nodes() + self.get_output_port_nodes() - for node in nodes: - if node in port_nodes and node.parent_port is not None: - # note: port nodes can only be deleted by deleting the parent - # port object. - raise NodeDeletionError( - '{} can\'t be deleted as it is attached to a port!' - .format(node) - ) - - super(SubGraph, self).delete_nodes(nodes, push_undo=push_undo) - - def collapse_graph(self, clear_session=True): - """ - Collapse the current sub graph and hide its widget. - - Args: - clear_session (bool): clear the current session. - """ - # update the group node. - serialized_session = self.serialize_session() - self.node.set_sub_graph_session(serialized_session) - - # close the visible widgets. - if self._undo_view: - self._undo_view.close() - - if self._subviewer_widget: - self.widget.hide_viewer(self._subviewer_widget) - - if clear_session: - self.clear_session() - - def expand_group_node(self, node): - """ - Expands a group node session in current sub view. - - Args: - node (NodeGraphQt.GroupNode): group node. - - Returns: - SubGraph: sub node graph used to manage the group node session. - """ - assert isinstance(node, GroupNode), 'node must be a GroupNode instance.' - if self._subviewer_widget is None: - raise RuntimeError('SubGraph.widget not initialized!') - - self.viewer().clear_key_state() - self.viewer().clearFocus() - - if node.id in self.sub_graphs: - sub_graph_viewer = self.sub_graphs[node.id].viewer() - sub_graph_viewer.setFocus() - return self.sub_graphs[node.id] - - # collapse expanded child sub graphs. - group_ids = [n.id for n in self.all_nodes() if isinstance(n, GroupNode)] - for grp_node_id, grp_sub_graph in self.sub_graphs.items(): - # collapse current group node. - if grp_node_id in group_ids: - grp_node = self.get_node_by_id(grp_node_id) - self.collapse_group_node(grp_node) - - # close the widgets - grp_sub_graph.collapse_graph(clear_session=False) - - # build new sub graph. - node_factory = copy.deepcopy(self.node_factory) - sub_graph = SubGraph(self, - node=node, - node_factory=node_factory, - layout_direction=self.layout_direction()) - - # populate the sub graph. - serialized_session = node.get_sub_graph_session() - sub_graph.deserialize_session(serialized_session) - - # open new sub graph view. - self.widget.add_viewer(sub_graph.subviewer_widget, - node.name(), - node.id) - - # store the references. - self.sub_graphs[node.id] = sub_graph - self.initialized_graphs.append(sub_graph) - - return sub_graph - - def collapse_group_node(self, node): - """ - Collapse a group node session and it's expanded child sub graphs. - - Args: - node (NodeGraphQt.GroupNode): group node. - """ - # update the references. - sub_graph = self.sub_graphs.pop(node.id, None) - if not sub_graph: - return - - init_idx = self.initialized_graphs.index(sub_graph) + 1 - for sgraph in reversed(self.initialized_graphs[init_idx:]): - self.initialized_graphs.remove(sgraph) - - # collapse child sub graphs here. - child_ids = [ - n.id for n in sub_graph.all_nodes() if isinstance(n, GroupNode) - ] - for child_id in child_ids: - if self.sub_graphs.get(child_id): - child_graph = self.sub_graphs.pop(child_id) - child_graph.collapse_graph(clear_session=True) - # remove child viewer widget. - self.widget.remove_viewer(child_graph.subviewer_widget) - - sub_graph.collapse_graph(clear_session=True) - self.widget.remove_viewer(sub_graph.subviewer_widget) - - def get_input_port_nodes(self): - """ - Return all the port nodes related to the group node input ports. - - .. image:: ../_images/port_in_node.png - :width: 150px - - - - - See Also: - :meth:`NodeGraph.get_nodes_by_type`, - :meth:`SubGraph.get_output_port_nodes` - - Returns: - list[NodeGraphQt.PortInputNode]: input nodes. - """ - return self.get_nodes_by_type(PortInputNode.type_) - - def get_output_port_nodes(self): - """ - Return all the port nodes related to the group node output ports. - - .. image:: ../_images/port_out_node.png - :width: 150px - - - - - See Also: - :meth:`NodeGraph.get_nodes_by_type`, - :meth:`SubGraph.get_input_port_nodes` - - Returns: - list[NodeGraphQt.PortOutputNode]: output nodes. - """ - return self.get_nodes_by_type(PortOutputNode.type_) - - def get_node_by_port(self, port): - """ - Returns the node related to the parent group node port object. - - Args: - port (NodeGraphQt.Port): parent node port object. - - Returns: - PortInputNode or PortOutputNode: port node object. - """ - func_type = { - PortTypeEnum.IN.value: self.get_input_port_nodes, - PortTypeEnum.OUT.value: self.get_output_port_nodes - } - for n in func_type.get(port.type_(), []): - if port == n.parent_port: - return n diff --git a/cuegui/NodeGraphQt/base/menu.py b/cuegui/NodeGraphQt/base/menu.py deleted file mode 100644 index 8270e9712..000000000 --- a/cuegui/NodeGraphQt/base/menu.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/python -import re -from distutils.version import LooseVersion - -from qtpy import QtGui, QtCore - -from NodeGraphQt.errors import NodeMenuError -from NodeGraphQt.widgets.actions import BaseMenu, GraphAction, NodeAction - - -class NodeGraphMenu(object): - """ - The ``NodeGraphMenu`` is the main context menu triggered from the node graph. - - .. inheritance-diagram:: NodeGraphQt.NodeGraphMenu - :parts: 1 - - example for accessing the node graph context menu. - - .. code-block:: python - :linenos: - - from NodeGraphQt import NodeGraph - - node_graph = NodeGraph() - - # get the context menu for the node graph. - context_menu = node_graph.get_context_menu('graph') - - """ - - def __init__(self, graph, qmenu): - self._graph = graph - self._qmenu = qmenu - self._name = qmenu.title() - self._menus = {} - self._commands = {} - self._items = [] - - def __repr__(self): - return '<{}("{}") object at {}>'.format( - self.__class__.__name__, self.name(), hex(id(self))) - - @property - def qmenu(self): - """ - The underlying QMenu. - - Returns: - BaseMenu: menu object. - """ - return self._qmenu - - def name(self): - """ - Returns the name for the menu. - - Returns: - str: label name. - """ - return self._name - - def get_items(self): - """ - Return the menu items in the order they were added. - - Returns: - list: current menu items. - """ - return self._items - - def get_menu(self, name): - """ - Returns the child menu by name. - - Args: - name (str): name of the menu. - - Returns: - NodeGraphQt.NodeGraphMenu: menu item. - """ - self._menus.get(name) - - def get_command(self, name): - """ - Returns the child menu command by name. - - Args: - name (str): name of the command. - - Returns: - NodeGraphQt.NodeGraphCommand: context menu command. - """ - return self._commands.get(name) - - def add_menu(self, name): - """ - Adds a child menu to the current menu. - - Args: - name (str): menu name. - - Returns: - NodeGraphQt.NodeGraphMenu: the appended menu item. - """ - if name in self._menus: - raise NodeMenuError('menu object "{}" already exists!'.format(name)) - base_menu = BaseMenu(name, self.qmenu) - self.qmenu.addMenu(base_menu) - menu = NodeGraphMenu(self._graph, base_menu) - self._menus[name] = menu - self._items.append(menu) - return menu - - @staticmethod - def _set_shortcut(action, shortcut): - if isinstance(shortcut, str): - search = re.search(r'(?:\.|)QKeySequence\.(\w+)', shortcut) - if search: - shortcut = getattr(QtGui.QKeySequence, search.group(1)) - elif all([i in ['Alt', 'Enter'] for i in shortcut.split('+')]): - shortcut = QtGui.QKeySequence( - QtCore.Qt.Modifier.ALT | QtCore.Qt.Key.Key_Return - ) - elif all([i in ['Return', 'Enter'] for i in shortcut.split('+')]): - shortcut = QtCore.Qt.Key.Key_Return - if shortcut: - action.setShortcut(shortcut) - - def add_command(self, name, func=None, shortcut=None): - """ - Adds a command to the menu. - - Args: - name (str): command name. - func (function): command function eg. "func(``graph``)". - shortcut (str): shortcut key. - - Returns: - NodeGraphQt.NodeGraphCommand: the appended command. - """ - action = GraphAction(name, self._graph.viewer()) - action.graph = self._graph - if LooseVersion(QtCore.qVersion()) >= LooseVersion('5.10'): - action.setShortcutVisibleInContextMenu(True) - - if shortcut: - self._set_shortcut(action, shortcut) - if func: - action.executed.connect(func) - self.qmenu.addAction(action) - command = NodeGraphCommand(self._graph, action, func) - self._commands[name] = command - self._items.append(command) - return command - - def add_separator(self): - """ - Adds a separator to the menu. - """ - self.qmenu.addSeparator() - self._items.append(None) - - -class NodesMenu(NodeGraphMenu): - """ - The ``NodesMenu`` is the context menu triggered from a node. - - .. inheritance-diagram:: NodeGraphQt.NodesMenu - :parts: 1 - - example for accessing the nodes context menu. - - .. code-block:: python - :linenos: - - from NodeGraphQt import NodeGraph - - node_graph = NodeGraph() - - # get the nodes context menu. - nodes_menu = node_graph.get_context_menu('nodes') - """ - - def add_command(self, name, func=None, node_type=None, node_class=None, - shortcut=None): - """ - Re-implemented to add a command to the specified node type menu. - - Args: - name (str): command name. - func (function): command function eg. "func(``graph``, ``node``)". - node_type (str): specified node type for the command. - node_class (class): specified node class for the command. - shortcut (str): shortcut key. - - Returns: - NodeGraphQt.NodeGraphCommand: the appended command. - """ - if not node_type and not node_class: - raise NodeMenuError('Node type or Node class not specified!') - - if node_class: - node_type = node_class.__name__ - - node_menu = self.qmenu.get_menu(node_type) - if not node_menu: - node_menu = BaseMenu(node_type, self.qmenu) - - if node_class: - node_menu.node_class = node_class - node_menu.graph = self._graph - - self.qmenu.addMenu(node_menu) - - if not self.qmenu.isEnabled(): - self.qmenu.setDisabled(False) - - action = NodeAction(name, self._graph.viewer()) - action.graph = self._graph - if LooseVersion(QtCore.qVersion()) >= LooseVersion('5.10'): - action.setShortcutVisibleInContextMenu(True) - - if shortcut: - self._set_shortcut(action, shortcut) - if func: - action.executed.connect(func) - - if node_class: - node_menus = self.qmenu.get_menus(node_class) - if node_menu in node_menus: - node_menus.remove(node_menu) - for menu in node_menus: - menu.addAction(action) - - node_menu.addAction(action) - command = NodeGraphCommand(self._graph, action, func) - self._commands[name] = command - self._items.append(command) - return command - - -class NodeGraphCommand(object): - """ - Node graph menu command. - - .. inheritance-diagram:: NodeGraphQt.NodeGraphCommand - :parts: 1 - - """ - - def __init__(self, graph, qaction, func=None): - self._graph = graph - self._qaction = qaction - self._name = qaction.text() - self._func = func - - def __repr__(self): - return '<{}("{}") object at {}>'.format( - self.__class__.__name__, self.name(), hex(id(self))) - - @property - def qaction(self): - """ - The underlying qaction. - - Returns: - GraphAction: qaction object. - """ - return self._qaction - - @property - def slot_function(self): - """ - The function executed by this command. - - Returns: - function: command function. - """ - return self._func - - def name(self): - """ - Returns the name for the menu command. - - Returns: - str: label name. - """ - return self._name - - def set_shortcut(self, shortcut=None): - """ - Sets the shortcut key combination for the menu command. - - Args: - shortcut (str): shortcut key. - """ - shortcut = shortcut or QtGui.QKeySequence() - self.qaction.setShortcut(shortcut) - - def run_command(self): - """ - execute the menu command. - """ - self.qaction.trigger() - - def set_enabled(self, state): - """ - Sets the command to either be enabled or disabled. - - Args: - state (bool): true to enable. - """ - self.qaction.setEnabled(state) - - def set_hidden(self, hidden): - """ - Sets then command item visibility in the context menu. - - Args: - hidden (bool): true to hide the command item. - """ - self.qaction.setVisible(not hidden) - - def show(self): - """ - Set the command to be visible in the context menu. - """ - self.qaction.setVisible(True) - - def hide(self): - """ - Set the command to be hidden in the context menu. - """ - self.qaction.setVisible(False) diff --git a/cuegui/NodeGraphQt/base/model.py b/cuegui/NodeGraphQt/base/model.py deleted file mode 100644 index 83c4e948d..000000000 --- a/cuegui/NodeGraphQt/base/model.py +++ /dev/null @@ -1,627 +0,0 @@ -#!/usr/bin/python -import json -from collections import defaultdict - -from NodeGraphQt.constants import ( - LayoutDirectionEnum, - NodePropWidgetEnum, - PipeLayoutEnum -) -from NodeGraphQt.errors import NodePropertyError - - -class PortModel(object): - """ - Data dump for a port object. - """ - - def __init__(self, node): - self.node = node - self.type_ = '' - self.name = 'port' - self.display_name = True - self.multi_connection = False - self.visible = True - self.locked = False - self.connected_ports = defaultdict(list) - - def __repr__(self): - return '<{}(\'{}\') object at {}>'.format( - self.__class__.__name__, self.name, hex(id(self))) - - @property - def to_dict(self): - """ - serialize model information to a dictionary. - - Returns: - dict: node port dictionary eg. - { - 'type': 'in', - 'name': 'port', - 'display_name': True, - 'multi_connection': False, - 'visible': True, - 'locked': False, - 'connected_ports': {: [, ]} - } - """ - props = self.__dict__.copy() - props.pop('node') - props['connected_ports'] = dict(props.pop('connected_ports')) - return props - - -class NodeModel(object): - """ - Data dump for a node object. - """ - - def __init__(self): - self.type_ = None - self.id = hex(id(self)) - self.icon = None - self.name = 'node' - self.color = (13, 18, 23, 255) - self.border_color = (74, 84, 85, 255) - self.text_color = (255, 255, 255, 180) - self.disabled = False - self.selected = False - self.visible = True - self.width = 100.0 - self.height = 80.0 - self.pos = [0.0, 0.0] - self.layout_direction = LayoutDirectionEnum.HORIZONTAL.value - - # BaseNode attrs. - self.inputs = {} - self.outputs = {} - self.port_deletion_allowed = False - - # GroupNode attrs. - self.subgraph_session = {} - - # Custom - self._custom_prop = {} - - # node graph model set at node added time. - self._graph_model = None - - # store the property attributes. - # (deleted when node is added to the graph) - self._TEMP_property_attrs = {} - - # temp store the property widget types. - # (deleted when node is added to the graph) - self._TEMP_property_widget_types = { - 'type_': NodePropWidgetEnum.QLABEL.value, - 'id': NodePropWidgetEnum.QLABEL.value, - 'icon': NodePropWidgetEnum.HIDDEN.value, - 'name': NodePropWidgetEnum.QLINE_EDIT.value, - 'color': NodePropWidgetEnum.COLOR_PICKER.value, - 'border_color': NodePropWidgetEnum.COLOR_PICKER.value, - 'text_color': NodePropWidgetEnum.COLOR_PICKER.value, - 'disabled': NodePropWidgetEnum.QCHECK_BOX.value, - 'selected': NodePropWidgetEnum.HIDDEN.value, - 'width': NodePropWidgetEnum.HIDDEN.value, - 'height': NodePropWidgetEnum.HIDDEN.value, - 'pos': NodePropWidgetEnum.HIDDEN.value, - 'layout_direction': NodePropWidgetEnum.HIDDEN.value, - 'inputs': NodePropWidgetEnum.HIDDEN.value, - 'outputs': NodePropWidgetEnum.HIDDEN.value, - } - - # temp store connection constrains. - # (deleted when node is added to the graph) - self._TEMP_accept_connection_types = {} - self._TEMP_reject_connection_types = {} - - def __repr__(self): - return '<{}(\'{}\') object at {}>'.format( - self.__class__.__name__, self.name, self.id) - - def add_property(self, name, value, items=None, range=None, - widget_type=None, widget_tooltip=None, tab=None): - """ - add custom property or raises an error if the property name is already - taken. - - Args: - name (str): name of the property. - value (object): data. - items (list[str]): items used by widget type NODE_PROP_QCOMBO. - range (tuple): min, max values used by NODE_PROP_SLIDER. - widget_type (int): widget type flag. - widget_tooltip (str): custom tooltip for the property widget. - tab (str): widget tab name. - """ - widget_type = widget_type or NodePropWidgetEnum.HIDDEN.value - tab = tab or 'Properties' - - if name in self.properties.keys(): - raise NodePropertyError( - '"{}" reserved for default property.'.format(name)) - if name in self._custom_prop.keys(): - raise NodePropertyError( - '"{}" property already exists.'.format(name)) - - self._custom_prop[name] = value - - if self._graph_model is None: - self._TEMP_property_widget_types[name] = widget_type - self._TEMP_property_attrs[name] = {'tab': tab} - if items: - self._TEMP_property_attrs[name]['items'] = items - if range: - self._TEMP_property_attrs[name]['range'] = range - if widget_tooltip: - self._TEMP_property_attrs[name]['tooltip'] = widget_tooltip - - else: - attrs = { - self.type_: { - name: { - 'widget_type': widget_type, - 'tab': tab - } - } - } - if items: - attrs[self.type_][name]['items'] = items - if range: - attrs[self.type_][name]['range'] = range - if widget_tooltip: - attrs[self.type_][name]['tooltip'] = widget_tooltip - self._graph_model.set_node_common_properties(attrs) - - def set_property(self, name, value): - """ - Args: - name (str): property name. - value (object): property value. - """ - if name in self.properties.keys(): - setattr(self, name, value) - elif name in self._custom_prop.keys(): - self._custom_prop[name] = value - else: - raise NodePropertyError('No property "{}"'.format(name)) - - def get_property(self, name): - """ - Args: - name (str): property name. - - Returns: - object: property value. - """ - if name in self.properties.keys(): - return self.properties[name] - return self._custom_prop.get(name) - - def is_custom_property(self, name): - """ - Args: - name (str): property name. - - Returns: - bool: true if custom property. - """ - return name in self._custom_prop - - def get_widget_type(self, name): - """ - Args: - name (str): property name. - - Returns: - int: node property widget type. - """ - model = self._graph_model - if model is None: - return self._TEMP_property_widget_types.get(name) - return model.get_node_common_properties(self.type_)[name]['widget_type'] - - def get_tab_name(self, name): - """ - Args: - name (str): property name. - - Returns: - str: name of the tab for the properties bin. - """ - model = self._graph_model - if model is None: - attrs = self._TEMP_property_attrs.get(name) - if attrs: - return attrs[name].get('tab') - return - return model.get_node_common_properties(self.type_)[name]['tab'] - - def add_port_accept_connection_type( - self, - port_name, port_type, node_type, - accept_pname, accept_ptype, accept_ntype - ): - """ - Convenience function for adding to the "accept_connection_types" dict. - If the node graph model is unavailable yet then we store it to a - temp var that gets deleted. - - Args: - port_name (str): current port name. - port_type (str): current port type. - node_type (str): current port node type. - accept_pname (str):port name to accept. - accept_ptype (str): port type accept. - accept_ntype (str):port node type to accept. - """ - model = self._graph_model - if model: - model.add_port_accept_connection_type( - port_name, port_type, node_type, - accept_pname, accept_ptype, accept_ntype - ) - return - - connection_data = self._TEMP_accept_connection_types - keys = [node_type, port_type, port_name, accept_ntype] - for key in keys: - if key not in connection_data.keys(): - connection_data[key] = {} - connection_data = connection_data[key] - - if accept_ptype not in connection_data: - connection_data[accept_ptype] = set([accept_pname]) - else: - connection_data[accept_ptype].add(accept_pname) - - def add_port_reject_connection_type( - self, - port_name, port_type, node_type, - reject_pname, reject_ptype, reject_ntype - ): - """ - Convenience function for adding to the "reject_connection_types" dict. - If the node graph model is unavailable yet then we store it to a - temp var that gets deleted. - - Args: - port_name (str): current port name. - port_type (str): current port type. - node_type (str): current port node type. - reject_pname: - reject_ptype: - reject_ntype: - - Returns: - - """ - model = self._graph_model - if model: - model.add_port_reject_connection_type( - port_name, port_type, node_type, - reject_pname, reject_ptype, reject_ntype - ) - return - - connection_data = self._TEMP_reject_connection_types - keys = [node_type, port_type, port_name, reject_ntype] - for key in keys: - if key not in connection_data.keys(): - connection_data[key] = {} - connection_data = connection_data[key] - - if reject_ptype not in connection_data: - connection_data[reject_ptype] = set([reject_pname]) - else: - connection_data[reject_ptype].add(reject_pname) - - @property - def properties(self): - """ - return all default node properties. - - Returns: - dict: default node properties. - """ - props = self.__dict__.copy() - exclude = ['_custom_prop', - '_graph_model', - '_TEMP_property_attrs', - '_TEMP_property_widget_types'] - [props.pop(i) for i in exclude if i in props.keys()] - return props - - @property - def custom_properties(self): - """ - return all custom properties specified by the user. - - Returns: - dict: user defined properties. - """ - return self._custom_prop - - @property - def to_dict(self): - """ - serialize model information to a dictionary. - - Returns: - dict: node id as the key and properties as the values eg. - {'0x106cf75a8': { - 'name': 'foo node', - 'color': (48, 58, 69, 255), - 'border_color': (85, 100, 100, 255), - 'text_color': (255, 255, 255, 180), - 'type_': 'io.github.jchanvfx.FooNode', - 'selected': False, - 'disabled': False, - 'visible': True, - 'width': 0.0, - 'height: 0.0, - 'pos': (0.0, 0.0), - 'layout_direction': 0, - 'custom': {}, - 'inputs': { - : {: [, ]} - }, - 'outputs': { - : {: [, ]} - }, - 'input_ports': [, ], - 'output_ports': [, ], - }, - subgraph_session: - } - """ - node_dict = self.__dict__.copy() - node_id = node_dict.pop('id') - - inputs = {} - outputs = {} - input_ports = [] - output_ports = [] - for name, model in node_dict.pop('inputs').items(): - if self.port_deletion_allowed: - input_ports.append({ - 'name': name, - 'multi_connection': model.multi_connection, - 'display_name': model.display_name, - }) - connected_ports = model.to_dict['connected_ports'] - if connected_ports: - inputs[name] = connected_ports - for name, model in node_dict.pop('outputs').items(): - if self.port_deletion_allowed: - output_ports.append({ - 'name': name, - 'multi_connection': model.multi_connection, - 'display_name': model.display_name, - }) - connected_ports = model.to_dict['connected_ports'] - if connected_ports: - outputs[name] = connected_ports - if inputs: - node_dict['inputs'] = inputs - if outputs: - node_dict['outputs'] = outputs - - if self.port_deletion_allowed: - node_dict['input_ports'] = input_ports - node_dict['output_ports'] = output_ports - - if self.subgraph_session: - node_dict['subgraph_session'] = self.subgraph_session - - custom_props = node_dict.pop('_custom_prop', {}) - if custom_props: - node_dict['custom'] = custom_props - - exclude = ['_graph_model', - '_TEMP_property_attrs', - '_TEMP_property_widget_types'] - [node_dict.pop(i) for i in exclude if i in node_dict.keys()] - - return {node_id: node_dict} - - @property - def serial(self): - """ - Serialize model information to a string. - - Returns: - str: serialized JSON string. - """ - model_dict = self.to_dict - return json.dumps(model_dict) - - -class NodeGraphModel(object): - """ - Data dump for a node graph. - """ - - def __init__(self): - self.__common_node_props = {} - - self.accept_connection_types = {} - self.reject_connection_types = {} - - self.nodes = {} - self.session = '' - self.acyclic = True - self.pipe_collision = False - self.pipe_slicing = True - self.pipe_style = PipeLayoutEnum.CURVED.value - self.layout_direction = LayoutDirectionEnum.HORIZONTAL.value - - def common_properties(self): - """ - Return all common node properties. - - Returns: - dict: common node properties. - eg. - {'nodeGraphQt.nodes.FooNode': { - 'my_property': { - 'widget_type': 0, - 'tab': 'Properties', - 'items': ['foo', 'bar', 'test'], - 'range': (0, 100) - } - } - } - """ - return self.__common_node_props - - def set_node_common_properties(self, attrs): - """ - Store common node properties. - - Args: - attrs (dict): common node properties. - eg. - {'nodeGraphQt.nodes.FooNode': { - 'my_property': { - 'widget_type': 0, - 'tab': 'Properties', - 'items': ['foo', 'bar', 'test'], - 'range': (0, 100) - } - } - } - """ - for node_type in attrs.keys(): - node_props = attrs[node_type] - - if node_type not in self.__common_node_props.keys(): - self.__common_node_props[node_type] = node_props - continue - - for prop_name, prop_attrs in node_props.items(): - common_props = self.__common_node_props[node_type] - if prop_name not in common_props.keys(): - common_props[prop_name] = prop_attrs - continue - common_props[prop_name].update(prop_attrs) - - def get_node_common_properties(self, node_type): - """ - Return all the common properties for a registered node. - - Args: - node_type (str): node type. - - Returns: - dict: node common properties. - """ - return self.__common_node_props.get(node_type) - - def add_port_accept_connection_type( - self, - port_name, port_type, node_type, - accept_pname, accept_ptype, accept_ntype - ): - """ - Convenience function for adding to the "accept_connection_types" dict. - - Args: - port_name (str): current port name. - port_type (str): current port type. - node_type (str): current port node type. - accept_pname (str):port name to accept. - accept_ptype (str): port type accept. - accept_ntype (str):port node type to accept. - """ - connection_data = self.accept_connection_types - keys = [node_type, port_type, port_name, accept_ntype] - for key in keys: - if key not in connection_data.keys(): - connection_data[key] = {} - connection_data = connection_data[key] - - if accept_ptype not in connection_data: - connection_data[accept_ptype] = [accept_pname] - else: - connection_data[accept_ptype].append(accept_pname) - - def port_accept_connection_types(self, node_type, port_type, port_name): - """ - Convenience function for getting the accepted port types from the - "accept_connection_types" dict. - - Args: - node_type (str): - port_type (str): - port_name (str): - - Returns: - dict: {: {: []}} - """ - data = self.accept_connection_types.get(node_type) or {} - accepted_types = data.get(port_type) or {} - return accepted_types.get(port_name) or {} - - def add_port_reject_connection_type( - self, - port_name, port_type, node_type, - reject_pname, reject_ptype, reject_ntype - ): - """ - Convenience function for adding to the "reject_connection_types" dict. - - Args: - port_name (str): current port name. - port_type (str): current port type. - node_type (str): current port node type. - reject_pname (str): port name to reject. - reject_ptype (str): port type to reject. - reject_ntype (str): port node type to reject. - """ - connection_data = self.reject_connection_types - keys = [node_type, port_type, port_name, reject_ntype] - for key in keys: - if key not in connection_data.keys(): - connection_data[key] = {} - connection_data = connection_data[key] - - if reject_ptype not in connection_data: - connection_data[reject_ptype] = [reject_pname] - else: - connection_data[reject_ptype].append(reject_pname) - - def port_reject_connection_types(self, node_type, port_type, port_name): - """ - Convenience function for getting the accepted port types from the - "reject_connection_types" dict. - - Args: - node_type (str): - port_type (str): - port_name (str): - - Returns: - dict: {: {: []}} - """ - data = self.reject_connection_types.get(node_type) or {} - rejected_types = data.get(port_type) or {} - return rejected_types.get(port_name) or {} - - -if __name__ == '__main__': - p = PortModel(None) - # print(p.to_dict) - - n = NodeModel() - n.inputs[p.name] = p - n.add_property('foo', 'bar') - - print('-'*100) - print('property keys\n') - print(list(n.properties.keys())) - print('-'*100) - print('to_dict\n') - for k, v in n.to_dict[n.id].items(): - print(k, v) diff --git a/cuegui/NodeGraphQt/base/node.py b/cuegui/NodeGraphQt/base/node.py deleted file mode 100644 index 9a5593b0e..000000000 --- a/cuegui/NodeGraphQt/base/node.py +++ /dev/null @@ -1,529 +0,0 @@ -#!/usr/bin/python -from NodeGraphQt.base.commands import PropertyChangedCmd -from NodeGraphQt.base.model import NodeModel -from NodeGraphQt.constants import NodePropWidgetEnum - - -class _ClassProperty(object): - - def __init__(self, f): - self.f = f - - def __get__(self, instance, owner): - return self.f(owner) - - -class NodeObject(object): - """ - The ``NodeGraphQt.NodeObject`` class is the main base class that all - nodes inherit from. - - .. inheritance-diagram:: NodeGraphQt.NodeObject - - Args: - qgraphics_item (AbstractNodeItem): QGraphicsItem item used for drawing. - """ - - __identifier__ = 'nodeGraphQt.nodes' - """ - Unique node identifier domain. eg. ``"io.github.jchanvfx"`` - - .. important:: re-implement this attribute to provide a unique node type. - - .. code-block:: python - :linenos: - - from NodeGraphQt import NodeObject - - class ExampleNode(NodeObject): - - # unique node identifier domain. - __identifier__ = 'io.github.jchanvfx' - - def __init__(self): - ... - - :return: node type domain. - :rtype: str - - :meta hide-value: - """ - - NODE_NAME = None - """ - Initial base node name. - - .. important:: re-implement this attribute to provide a base node name. - - .. code-block:: python - :linenos: - - from NodeGraphQt import NodeObject - - class ExampleNode(NodeObject): - - # initial default node name. - NODE_NAME = 'Example Node' - - def __init__(self): - ... - - :return: node name - :rtype: str - - :meta hide-value: - """ - - def __init__(self, qgraphics_item=None): - """ - Args: - qgraphics_item (AbstractNodeItem): QGraphicsItem used for drawing. - """ - self._graph = None - self._model = NodeModel() - self._model.type_ = self.type_ - self._model.name = self.NODE_NAME - - _NodeItem = qgraphics_item - if _NodeItem is None: - raise RuntimeError( - 'No qgraphics item specified for the node object!' - ) - - self._view = _NodeItem() - self._view.type_ = self.type_ - self._view.name = self.model.name - self._view.id = self._model.id - self._view.layout_direction = self._model.layout_direction - - def __repr__(self): - return '<{}("{}") object at {}>'.format( - self.__class__.__name__, self.NODE_NAME, hex(id(self))) - - @_ClassProperty - def type_(cls): - """ - Node type identifier followed by the class name. - `eg.` ``"nodeGraphQt.nodes.NodeObject"`` - - Returns: - str: node type (``__identifier__.__className__``) - """ - return cls.__identifier__ + '.' + cls.__name__ - - @property - def id(self): - """ - The node unique id. - - Returns: - str: unique identifier string to the node. - """ - return self.model.id - - @property - def graph(self): - """ - The parent node graph. - - Returns: - NodeGraphQt.NodeGraph: node graph instance. - """ - return self._graph - - @property - def view(self): - """ - Returns the :class:`QtWidgets.QGraphicsItem` used in the scene. - - Returns: - NodeGraphQt.qgraphics.node_abstract.AbstractNodeItem: node item. - """ - return self._view - - def set_view(self, item): - """ - Set a new ``QGraphicsItem`` item to be used as the view. - (the provided qgraphics item must be subclassed from the - ``AbstractNodeItem`` object.) - - Args: - item (NodeGraphQt.qgraphics.node_abstract.AbstractNodeItem): node item. - """ - if self._view: - old_view = self._view - scene = self._view.scene() - scene.removeItem(old_view) - self._view = item - scene.addItem(self._view) - else: - self._view = item - self.NODE_NAME = self._view.name - - # update the view. - self.update() - - @property - def model(self): - """ - Return the node model. - - Returns: - NodeGraphQt.base.model.NodeModel: node model object. - """ - return self._model - - def set_model(self, model): - """ - Set a new model to the node model. - (Setting a new node model will also update the views qgraphics item.) - - Args: - model (NodeGraphQt.base.model.NodeModel): node model object. - """ - self._model = model - self._model.type_ = self.type_ - self._model.id = self.view.id - - # update the view. - self.update() - - def update_model(self): - """ - Update the node model from view. - """ - for name, val in self.view.properties.items(): - if name in self.model.properties.keys(): - setattr(self.model, name, val) - if name in self.model.custom_properties.keys(): - self.model.custom_properties[name] = val - - def update(self): - """ - Update the node view from model. - """ - settings = self.model.to_dict[self.model.id] - settings['id'] = self.model.id - self.view.from_dict(settings) - - def serialize(self): - """ - Serialize node model to a dictionary. - - example: - - .. highlight:: python - .. code-block:: python - - {'0x106cf75a8': { - 'name': 'foo node', - 'color': (48, 58, 69, 255), - 'border_color': (85, 100, 100, 255), - 'text_color': (255, 255, 255, 180), - 'type': 'io.github.jchanvfx.MyNode', - 'selected': False, - 'disabled': False, - 'visible': True, - 'inputs': { - : {: [, ]} - }, - 'outputs': { - : {: [, ]} - }, - 'input_ports': [, ], - 'output_ports': [, ], - 'width': 0.0, - 'height: 0.0, - 'pos': (0.0, 0.0), - 'layout_direction': 0, - 'custom': {}, - } - } - - Returns: - dict: serialized node - """ - return self.model.to_dict - - def name(self): - """ - Name of the node. - - Returns: - str: name of the node. - """ - return self.model.name - - def set_name(self, name=''): - """ - Set the name of the node. - - Args: - name (str): name for the node. - """ - self.set_property('name', name) - - def color(self): - """ - Returns the node color in (red, green, blue) value. - - Returns: - tuple: ``(r, g, b)`` from ``0-255`` range. - """ - r, g, b, a = self.model.color - return r, g, b - - def set_color(self, r=0, g=0, b=0): - """ - Sets the color of the node in (red, green, blue) value. - - Args: - r (int): red value ``0-255`` range. - g (int): green value ``0-255`` range. - b (int): blue value ``0-255`` range. - """ - self.set_property('color', (r, g, b, 255)) - - def disabled(self): - """ - Returns whether the node is enabled or disabled. - - Returns: - bool: True if the node is disabled. - """ - return self.model.disabled - - def set_disabled(self, mode=False): - """ - Set the node state to either disabled or enabled. - - Args: - mode(bool): True to disable node. - """ - self.set_property('disabled', mode) - - def selected(self): - """ - Returns the selected state of the node. - - Returns: - bool: True if the node is selected. - """ - self.model.selected = self.view.isSelected() - return self.model.selected - - def set_selected(self, selected=True): - """ - Set the node to be selected or not selected. - - Args: - selected (bool): True to select the node. - """ - self.set_property('selected', selected) - - def create_property(self, name, value, items=None, range=None, - widget_type=None, widget_tooltip=None, tab=None): - """ - Creates a custom property to the node. - - See Also: - Custom node properties bin widget - :class:`NodeGraphQt.PropertiesBinWidget` - - Hint: - To see all the available property widget types to display in - the ``PropertiesBinWidget`` widget checkout - :attr:`NodeGraphQt.constants.NodePropWidgetEnum`. - - Args: - name (str): name of the property. - value (object): data. - items (list[str]): items used by widget type - attr:`NodeGraphQt.constants.NodePropWidgetEnum.QCOMBO_BOX` - range (tuple or list): ``(min, max)`` values used by - :attr:`NodeGraphQt.constants.NodePropWidgetEnum.SLIDER` - widget_type (int): widget flag to display in the - :class:`NodeGraphQt.PropertiesBinWidget` - widget_tooltip (str): widget tooltip for the property widget - displayed in the :class:`NodeGraphQt.PropertiesBinWidget` - tab (str): name of the widget tab to display in the - :class:`NodeGraphQt.PropertiesBinWidget`. - """ - widget_type = widget_type or NodePropWidgetEnum.HIDDEN.value - self.model.add_property( - name, value, items, range, widget_type, widget_tooltip, tab - ) - - def properties(self): - """ - Returns all the node properties. - - Returns: - dict: a dictionary of node properties. - """ - props = self.model.to_dict[self.id].copy() - props['id'] = self.id - return props - - def get_property(self, name): - """ - Return the node custom property. - - Args: - name (str): name of the property. - - Returns: - object: property data. - """ - if self.graph and name == 'selected': - self.model.set_property(name, self.view.selected) - - return self.model.get_property(name) - - def set_property(self, name, value, push_undo=True): - """ - Set the value on the node custom property. - - Note: - When setting the node ``"name"`` property a new unique name will be - used if another node in the graph has the same node name. - - Args: - name (str): name of the property. - value (object): property data (python built in types). - push_undo (bool): register the command to the undo stack. (default: True) - """ - - # prevent signals from causing an infinite loop. - if self.get_property(name) == value: - return - - # prevent nodes from have the same name. - if self.graph and name == 'name': - value = self.graph.get_unique_name(value) - self.NODE_NAME = value - - if self.graph: - undo_cmd = PropertyChangedCmd(self, name, value) - if name == 'name': - undo_cmd.setText( - 'renamed "{}" to "{}"'.format(self.name(), value) - ) - if push_undo: - undo_stack = self.graph.undo_stack() - undo_stack.push(undo_cmd) - else: - undo_cmd.redo() - else: - if hasattr(self.view, name): - setattr(self.view, name, value) - self.model.set_property(name, value) - - # redraw the node for custom properties. - if self.model.is_custom_property(name): - self.view.draw_node() - - def has_property(self, name): - """ - Check if node custom property exists. - - Args: - name (str): name of the node. - - Returns: - bool: true if property name exists in the Node. - """ - return name in self.model.custom_properties.keys() - - def set_x_pos(self, x): - """ - Set the node horizontal X position in the node graph. - - Args: - x (float or int): node X position. - """ - y = self.pos()[1] - self.set_pos(float(x), y) - - def set_y_pos(self, y): - """ - Set the node horizontal Y position in the node graph. - - Args: - y (float or int): node Y position. - """ - - x = self.pos()[0] - self.set_pos(x, float(y)) - - def set_pos(self, x, y): - """ - Set the node X and Y position in the node graph. - - Args: - x (float or int): node X position. - y (float or int): node Y position. - """ - self.set_property('pos', [float(x), float(y)]) - - def x_pos(self): - """ - Get the node X position in the node graph. - - Returns: - float: x position. - """ - return self.model.pos[0] - - def y_pos(self): - """ - Get the node Y position in the node graph. - - Returns: - float: y position. - """ - return self.model.pos[1] - - def pos(self): - """ - Get the node XY position in the node graph. - - Returns: - list[float, float]: x, y position. - """ - if self.view.xy_pos and self.view.xy_pos != self.model.pos: - self.model.pos = self.view.xy_pos - - return self.model.pos - - def layout_direction(self): - """ - Returns layout direction for this node. - - See Also: - :meth:`NodeObject.set_layout_direction` - - Returns: - int: node layout direction. - """ - return self.model.layout_direction - - def set_layout_direction(self, value=0): - """ - Sets the node layout direction to either horizontal or vertical on - the current node only. - - `Implemented in` ``v0.3.0`` - - See Also: - :meth:`NodeGraph.set_layout_direction` - :meth:`NodeObject.layout_direction` - - Warnings: - This function does not register to the undo stack. - - Args: - value (int): layout direction mode. - """ - self.model.layout_direction = value - self.view.layout_direction = value diff --git a/cuegui/NodeGraphQt/base/port.py b/cuegui/NodeGraphQt/base/port.py deleted file mode 100644 index d38820a61..000000000 --- a/cuegui/NodeGraphQt/base/port.py +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/python -from NodeGraphQt.base.commands import ( - PortConnectedCmd, - PortDisconnectedCmd, - PortLockedCmd, - PortUnlockedCmd, - PortVisibleCmd, - NodeInputConnectedCmd, - NodeInputDisconnectedCmd -) -from NodeGraphQt.base.model import PortModel -from NodeGraphQt.constants import PortTypeEnum -from NodeGraphQt.errors import PortError - - -class Port(object): - """ - The ``Port`` class is used for connecting one node to another. - - .. inheritance-diagram:: NodeGraphQt.Port - - .. image:: _images/port.png - :width: 50% - - See Also: - For adding a ports into a node see: - :meth:`BaseNode.add_input`, :meth:`BaseNode.add_output` - - Args: - node (NodeGraphQt.NodeObject): parent node. - port (PortItem): graphic item used for drawing. - """ - - def __init__(self, node, port): - self.__view = port - self.__model = PortModel(node) - - def __repr__(self): - port = str(self.__class__.__name__) - return '<{}("{}") object at {}>'.format( - port, self.name(), hex(id(self))) - - @property - def view(self): - """ - Returns the :class:`QtWidgets.QGraphicsItem` used in the scene. - - Returns: - NodeGraphQt.qgraphics.port.PortItem: port item. - """ - return self.__view - - @property - def model(self): - """ - Returns the port model. - - Returns: - NodeGraphQt.base.model.PortModel: port model. - """ - return self.__model - - def type_(self): - """ - Returns the port type. - - Port Types: - - :attr:`NodeGraphQt.constants.IN_PORT` for input port - - :attr:`NodeGraphQt.constants.OUT_PORT` for output port - - Returns: - str: port connection type. - """ - return self.model.type_ - - def multi_connection(self): - """ - Returns if the ports is a single connection or not. - - Returns: - bool: false if port is a single connection port - """ - return self.model.multi_connection - - def node(self): - """ - Return the parent node. - - Returns: - NodeGraphQt.BaseNode: parent node object. - """ - return self.model.node - - def name(self): - """ - Returns the port name. - - Returns: - str: port name. - """ - return self.model.name - - def visible(self): - """ - Port visible in the node graph. - - Returns: - bool: true if visible. - """ - return self.model.visible - - def set_visible(self, visible=True, push_undo=True): - """ - Sets weather the port should be visible or not. - - Args: - visible (bool): true if visible. - push_undo (bool): register the command to the undo stack. (default: True) - """ - - # prevent signals from causing an infinite loop. - if visible == self.visible(): - return - - undo_cmd = PortVisibleCmd(self, visible) - if push_undo: - undo_stack = self.node().graph.undo_stack() - undo_stack.push(undo_cmd) - else: - undo_cmd.redo() - - def locked(self): - """ - Returns the locked state. - - If ports are locked then new pipe connections can't be connected - and current connected pipes can't be disconnected. - - Returns: - bool: true if locked. - """ - return self.model.locked - - def lock(self): - """ - Lock the port so new pipe connections can't be connected and - current connected pipes can't be disconnected. - - This is the same as calling :meth:`Port.set_locked` with the arg - set to ``True`` - """ - self.set_locked(True, connected_ports=True) - - def unlock(self): - """ - Unlock the port so new pipe connections can be connected and - existing connected pipes can be disconnected. - - This is the same as calling :meth:`Port.set_locked` with the arg - set to ``False`` - """ - self.set_locked(False, connected_ports=True) - - def set_locked(self, state=False, connected_ports=True, push_undo=True): - """ - Sets the port locked state. When locked pipe connections can't be - connected or disconnected from this port. - - Args: - state (Bool): port lock state. - connected_ports (Bool): apply to lock state to connected ports. - push_undo (bool): register the command to the undo stack. (default: True) - """ - - # prevent signals from causing an infinite loop. - if state == self.locked(): - return - - graph = self.node().graph - undo_stack = graph.undo_stack() - if state: - undo_cmd = PortLockedCmd(self) - else: - undo_cmd = PortUnlockedCmd(self) - if push_undo: - undo_stack.push(undo_cmd) - else: - undo_cmd.redo() - if connected_ports: - for port in self.connected_ports(): - port.set_locked(state, - connected_ports=False, - push_undo=push_undo) - - def connected_ports(self): - """ - Returns all connected ports. - - Returns: - list[NodeGraphQt.Port]: list of connected ports. - """ - ports = [] - graph = self.node().graph - for node_id, port_names in self.model.connected_ports.items(): - for port_name in port_names: - node = graph.get_node_by_id(node_id) - if self.type_() == PortTypeEnum.IN.value: - ports.append(node.outputs()[port_name]) - elif self.type_() == PortTypeEnum.OUT.value: - ports.append(node.inputs()[port_name]) - return ports - - def connect_to(self, port=None, push_undo=True, emit_signal=True): - """ - Create connection to the specified port and emits the - :attr:`NodeGraph.port_connected` signal from the parent node graph. - - Args: - port (NodeGraphQt.Port): port object. - push_undo (bool): register the command to the undo stack. (default: True) - emit_signal (bool): emit the port connection signals. (default: True) - """ - if not port: - return - - if self in port.connected_ports(): - return - - if self.locked() or port.locked(): - name = [p.name() for p in [self, port] if p.locked()][0] - raise PortError( - 'Can\'t connect port because "{}" is locked.'.format(name)) - - # validate accept connection. - node_type = self.node().type_ - accepted_types = port.accepted_port_types().get(node_type) - if accepted_types: - accepted_pnames = accepted_types.get(self.type_()) or set([]) - if self.name() not in accepted_pnames: - return - node_type = port.node().type_ - accepted_types = self.accepted_port_types().get(node_type) - if accepted_types: - accepted_pnames = accepted_types.get(port.type_()) or set([]) - if port.name() not in accepted_pnames: - return - - # validate reject connection. - node_type = self.node().type_ - rejected_types = port.rejected_port_types().get(node_type) - if rejected_types: - rejected_pnames = rejected_types.get(self.type_()) or set([]) - if self.name() in rejected_pnames: - return - node_type = port.node().type_ - rejected_types = self.rejected_port_types().get(node_type) - if rejected_types: - rejected_pnames = rejected_types.get(port.type_()) or set([]) - if port.name() in rejected_pnames: - return - - # make the connection from here. - graph = self.node().graph - viewer = graph.viewer() - - if push_undo: - undo_stack = graph.undo_stack() - undo_stack.beginMacro('connect port') - - pre_conn_port = None - src_conn_ports = self.connected_ports() - if not self.multi_connection() and src_conn_ports: - pre_conn_port = src_conn_ports[0] - - if not port: - if pre_conn_port: - if push_undo: - undo_stack.push( - PortDisconnectedCmd(self, port, emit_signal) - ) - undo_stack.push(NodeInputDisconnectedCmd(self, port)) - undo_stack.endMacro() - else: - PortDisconnectedCmd(self, port, emit_signal).redo() - NodeInputDisconnectedCmd(self, port).redo() - return - - if graph.acyclic() and viewer.acyclic_check(self.view, port.view): - if pre_conn_port: - if push_undo: - undo_stack.push( - PortDisconnectedCmd(self, pre_conn_port, emit_signal) - ) - undo_stack.push(NodeInputDisconnectedCmd( - self, pre_conn_port) - ) - undo_stack.endMacro() - else: - PortDisconnectedCmd(self, pre_conn_port, emit_signal).redo() - NodeInputDisconnectedCmd(self, pre_conn_port).redo() - return - - trg_conn_ports = port.connected_ports() - if not port.multi_connection() and trg_conn_ports: - dettached_port = trg_conn_ports[0] - if push_undo: - undo_stack.push( - PortDisconnectedCmd(port, dettached_port, emit_signal) - ) - undo_stack.push(NodeInputDisconnectedCmd(port, dettached_port)) - else: - PortDisconnectedCmd(port, dettached_port, emit_signal).redo() - NodeInputDisconnectedCmd(port, dettached_port).redo() - if pre_conn_port: - if push_undo: - undo_stack.push( - PortDisconnectedCmd(self, pre_conn_port, emit_signal) - ) - undo_stack.push(NodeInputDisconnectedCmd(self, pre_conn_port)) - else: - PortDisconnectedCmd(self, pre_conn_port, emit_signal).redo() - NodeInputDisconnectedCmd(self, pre_conn_port).redo() - - if push_undo: - undo_stack.push(PortConnectedCmd(self, port, emit_signal)) - undo_stack.push(NodeInputConnectedCmd(self, port)) - undo_stack.endMacro() - else: - PortConnectedCmd(self, port, emit_signal).redo() - NodeInputConnectedCmd(self, port).redo() - - def disconnect_from(self, port=None, push_undo=True, emit_signal=True): - """ - Disconnect from the specified port and emits the - :attr:`NodeGraph.port_disconnected` signal from the parent node graph. - - Args: - port (NodeGraphQt.Port): port object. - push_undo (bool): register the command to the undo stack. (default: True) - emit_signal (bool): emit the port connection signals. (default: True) - """ - if not port: - return - - if self.locked() or port.locked(): - name = [p.name() for p in [self, port] if p.locked()][0] - raise PortError( - 'Can\'t disconnect port because "{}" is locked.'.format(name)) - - graph = self.node().graph - if push_undo: - graph.undo_stack().beginMacro('disconnect port') - graph.undo_stack().push(PortDisconnectedCmd(self, port, emit_signal)) - graph.undo_stack().push(NodeInputDisconnectedCmd(self, port)) - graph.undo_stack().endMacro() - else: - PortDisconnectedCmd(self, port, emit_signal).redo() - NodeInputDisconnectedCmd(self, port).redo() - - def clear_connections(self, push_undo=True, emit_signal=True): - """ - Disconnect from all port connections and emit the - :attr:`NodeGraph.port_disconnected` signals from the node graph. - - See Also: - :meth:`Port.disconnect_from`, - :meth:`Port.connect_to`, - :meth:`Port.connected_ports` - - Args: - push_undo (bool): register the command to the undo stack. (default: True) - emit_signal (bool): emit the port connection signals. (default: True) - """ - if self.locked(): - err = 'Can\'t clear connections because port "{}" is locked.' - raise PortError(err.format(self.name())) - - if not self.connected_ports(): - return - - if push_undo: - graph = self.node().graph - undo_stack = graph.undo_stack() - undo_stack.beginMacro('"{}" clear connections') - for cp in self.connected_ports(): - self.disconnect_from(cp, emit_signal=emit_signal) - undo_stack.endMacro() - return - - for cp in self.connected_ports(): - self.disconnect_from( - cp, push_undo=False, emit_signal=emit_signal - ) - - def add_accept_port_type(self, port_name, port_type, node_type): - """ - Add a constraint to "accept" a pipe connection. - - Once a constraint has been added only ports of that type specified will - be allowed a pipe connection. - - `Implemented in` ``v0.6.0`` - - See Also: - :meth:`NodeGraphQt.Port.add_reject_ports_type`, - :meth:`NodeGraphQt.BaseNode.add_accept_port_type` - - Args: - port_name (str): name of the port. - port_type (str): port type. - node_type (str): port node type. - """ - # storing the connection constrain at the graph level instead of the - # port level, so we don't serialize the same data for every port - # instance. - self.node().add_accept_port_type( - port=self, - port_type_data={ - 'port_name': port_name, - 'port_type': port_type, - 'node_type': node_type, - } - ) - - def accepted_port_types(self): - """ - Returns a dictionary of connection constrains of the port types - that allow for a pipe connection to this node. - - See Also: - :meth:`NodeGraphQt.BaseNode.accepted_port_types` - - Returns: - dict: {: {: []}} - """ - return self.node().accepted_port_types(self) - - def add_reject_port_type(self, port_name, port_type, node_type): - """ - Add a constraint to "reject" a pipe connection. - - Once a constraint has been added only ports of that type specified will - be rejected a pipe connection. - - `Implemented in` ``v0.6.0`` - - See Also: - :meth:`NodeGraphQt.Port.add_accept_ports_type`, - :meth:`NodeGraphQt.BaseNode.add_reject_port_type` - - Args: - port_name (str): name of the port. - port_type (str): port type. - node_type (str): port node type. - """ - # storing the connection constrain at the graph level instead of the - # port level, so we don't serialize the same data for every port - # instance. - self.node().add_reject_port_type( - port=self, - port_type_data={ - 'port_name': port_name, - 'port_type': port_type, - 'node_type': node_type, - } - ) - - def rejected_port_types(self): - """ - Returns a dictionary of connection constrains of the port types - that are NOT allowed for a pipe connection to this node. - - See Also: - :meth:`NodeGraphQt.BaseNode.rejected_port_types` - - Returns: - dict: {: {: []}} - """ - return self.node().rejected_port_types(self) - - @property - def color(self): - return self.__view.color - - @color.setter - def color(self, color=(0, 0, 0, 255)): - self.__view.color = color - - @property - def border_color(self): - return self.__view.border_color - - @border_color.setter - def border_color(self, color=(0, 0, 0, 255)): - self.__view.border_color = color diff --git a/cuegui/NodeGraphQt/constants.py b/cuegui/NodeGraphQt/constants.py deleted file mode 100644 index af0c412ee..000000000 --- a/cuegui/NodeGraphQt/constants.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -import os - -from qtpy import QtWidgets -from enum import Enum - -from .pkg_info import __version__ as _v - -__doc__ = """ -| The :py:mod:`NodeGraphQt.constants` namespace contains variables and enums - used throughout the NodeGraphQt library. -""" - -# ================================== PRIVATE =================================== - -MIME_TYPE = 'nodegraphqt/nodes' -URI_SCHEME = 'nodegraphqt://' -URN_SCHEME = 'nodegraphqt::' - -# PATHS -BASE_PATH = os.path.dirname(os.path.abspath(__file__)) -ICON_PATH = os.path.join(BASE_PATH, 'widgets', 'icons') -ICON_DOWN_ARROW = os.path.join(ICON_PATH, 'down_arrow.png') -ICON_NODE_BASE = os.path.join(ICON_PATH, 'node_base.png') - -# DRAW STACK ORDER -Z_VAL_BACKDROP = -2 -Z_VAL_PIPE = -1 -Z_VAL_NODE = 1 -Z_VAL_PORT = 2 -Z_VAL_NODE_WIDGET = 3 - -# ITEM CACHE MODE -# QGraphicsItem.NoCache -# QGraphicsItem.DeviceCoordinateCache -# QGraphicsItem.ItemCoordinateCache -ITEM_CACHE_MODE = QtWidgets.QGraphicsItem.DeviceCoordinateCache - -# =================================== GLOBAL =================================== - - -class VersionEnum(Enum): - """ - Current framework version. - :py:mod:`NodeGraphQt.constants.VersionEnum` - """ - #: current version string. - VERSION = _v - #: version major int. - MAJOR = int(_v.split('.')[0]) - #: version minor int. - MINOR = int(_v.split('.')[1]) - #: version patch int. - PATCH = int(_v.split('.')[2]) - - -class LayoutDirectionEnum(Enum): - """ - Node graph nodes layout direction: - :py:mod:`NodeGraphQt.constants.ViewerLayoutEnum` - """ - #: layout nodes left to right. - HORIZONTAL = 0 - #: layout nodes top to bottom. - VERTICAL = 1 - - -# =================================== VIEWER =================================== - - -class ViewerEnum(Enum): - """ - Node graph viewer styling layout: - :py:mod:`NodeGraphQt.constants.ViewerEnum` - """ - #: default background color for the node graph. - BACKGROUND_COLOR = (35, 35, 35) - #: style node graph background with no grid or dots. - GRID_DISPLAY_NONE = 0 - #: style node graph background with dots. - GRID_DISPLAY_DOTS = 1 - #: style node graph background with grid lines. - GRID_DISPLAY_LINES = 2 - #: grid size when styled with grid lines. - GRID_SIZE = 50 - #: grid line color. - GRID_COLOR = (45, 45, 45) - - -class ViewerNavEnum(Enum): - """ - Node graph viewer navigation styling layout: - :py:mod:`NodeGraphQt.constants.ViewerNavEnum` - """ - #: default background color. - BACKGROUND_COLOR = (25, 25, 25) - #: default item color. - ITEM_COLOR = (35, 35, 35) - -# ==================================== NODE ==================================== - - -class NodeEnum(Enum): - """ - Node styling layout: - :py:mod:`NodeGraphQt.constants.NodeEnum` - """ - #: default node width. - WIDTH = 160 - #: default node height. - HEIGHT = 60 - #: default node icon size (WxH). - ICON_SIZE = 18 - #: default node overlay color when selected. - SELECTED_COLOR = (255, 255, 255, 30) - #: default node border color when selected. - SELECTED_BORDER_COLOR = (254, 207, 42, 255) - -# ==================================== PORT ==================================== - - -class PortEnum(Enum): - """ - Port styling layout: - :py:mod:`NodeGraphQt.constants.PortEnum` - """ - #: default port size. - SIZE = 22.0 - #: default port color. (r, g, b, a) - COLOR = (49, 115, 100, 255) - #: default port border color. - BORDER_COLOR = (29, 202, 151, 255) - #: port color when selected. - ACTIVE_COLOR = (14, 45, 59, 255) - #: port border color when selected. - ACTIVE_BORDER_COLOR = (107, 166, 193, 255) - #: port color on mouse over. - HOVER_COLOR = (17, 43, 82, 255) - #: port border color on mouse over. - HOVER_BORDER_COLOR = (136, 255, 35, 255) - #: threshold for selecting a port. - CLICK_FALLOFF = 15.0 - - -class PortTypeEnum(Enum): - """ - Port connection types: - :py:mod:`NodeGraphQt.constants.PortTypeEnum` - """ - #: Connection type for input ports. - IN = 'in' - #: Connection type for output ports. - OUT = 'out' - -# ==================================== PIPE ==================================== - - -class PipeEnum(Enum): - """ - Pipe styling layout: - :py:mod:`NodeGraphQt.constants.PipeEnum` - """ - #: default width. - WIDTH = 1.2 - #: default color. - COLOR = (175, 95, 30, 255) - #: pipe color to a node when it's disabled. - DISABLED_COLOR = (200, 60, 60, 255) - #: pipe color when selected or mouse over. - ACTIVE_COLOR = (70, 255, 220, 255) - #: pipe color to a node when it's selected. - HIGHLIGHT_COLOR = (232, 184, 13, 255) - #: draw connection as a line. - DRAW_TYPE_DEFAULT = 0 - #: draw connection as dashed lines. - DRAW_TYPE_DASHED = 1 - #: draw connection as a dotted line. - DRAW_TYPE_DOTTED = 2 - - -class PipeSlicerEnum(Enum): - """ - Slicer Pipe styling layout: - :py:mod:`NodeGraphQt.constants.PipeSlicerEnum` - """ - #: default width. - WIDTH = 1.5 - #: default color. - COLOR = (255, 50, 75) - - -class PipeLayoutEnum(Enum): - """ - Pipe connection drawing layout: - :py:mod:`NodeGraphQt.constants.PipeLayoutEnum` - """ - #: draw straight lines for pipe connections. - STRAIGHT = 0 - #: draw curved lines for pipe connections. - CURVED = 1 - #: draw angled lines for pipe connections. - ANGLE = 2 - - -# === PROPERTY BIN WIDGET === - -class NodePropWidgetEnum(Enum): - """ - Mapping used for the :class:`NodeGraphQt.PropertiesBinWidget` to display a - node property in the specified widget type. - - :py:mod:`NodeGraphQt.constants.NodePropWidgetEnum` - """ - #: Node property will be hidden in the ``PropertiesBinWidget`` (default). - HIDDEN = 0 - #: Node property represented with a ``QLabel`` widget. - QLABEL = 2 - #: Node property represented with a ``QLineEdit`` widget. - QLINE_EDIT = 3 - #: Node property represented with a ``QTextEdit`` widget. - QTEXT_EDIT = 4 - #: Node property represented with a ``QComboBox`` widget. - QCOMBO_BOX = 5 - #: Node property represented with a ``QCheckBox`` widget. - QCHECK_BOX = 6 - #: Node property represented with a ``QSpinBox`` widget. - QSPIN_BOX = 7 - #: Node property represented with a ``QDoubleSpinBox`` widget. - QDOUBLESPIN_BOX = 8 - #: Node property represented with a ColorPicker widget. - COLOR_PICKER = 9 - #: Node property represented with a ColorPicker (RGBA) widget. - COLOR4_PICKER = 10 - #: Node property represented with an (Int) Slider widget. - SLIDER = 11 - #: Node property represented with a (Dobule) Slider widget. - DOUBLE_SLIDER = 12 - #: Node property represented with a file selector widget. - FILE_OPEN = 13 - #: Node property represented with a file save widget. - FILE_SAVE = 14 - #: Node property represented with a vector2 widget. - VECTOR2 = 15 - #: Node property represented with vector3 widget. - VECTOR3 = 16 - #: Node property represented with vector4 widget. - VECTOR4 = 17 - #: Node property represented with float line edit widget. - FLOAT = 18 - #: Node property represented with int line edit widget. - INT = 19 - #: Node property represented with button widget. - BUTTON = 20 diff --git a/cuegui/NodeGraphQt/custom_widgets/__init__.py b/cuegui/NodeGraphQt/custom_widgets/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cuegui/NodeGraphQt/custom_widgets/nodes_palette.py b/cuegui/NodeGraphQt/custom_widgets/nodes_palette.py deleted file mode 100644 index d4563d653..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/nodes_palette.py +++ /dev/null @@ -1,346 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -from collections import defaultdict - -from qtpy import QtWidgets, QtCore, QtGui - -from NodeGraphQt.constants import MIME_TYPE, URN_SCHEME - - -class _NodesGridDelegate(QtWidgets.QStyledItemDelegate): - - def paint(self, painter, option, index): - """ - Args: - painter (QtGui.QPainter): - option (QtGui.QStyleOptionViewItem): - index (QtCore.QModelIndex): - """ - if index.column() != 0: - super(_NodesGridDelegate, self).paint(painter, option, index) - return - - model = index.model().sourceModel() - item = model.item(index.row(), index.column()) - - sub_margin = 2 - radius = 5 - - base_rect = QtCore.QRectF( - option.rect.x() + sub_margin, - option.rect.y() + sub_margin, - option.rect.width() - (sub_margin * 2), - option.rect.height() - (sub_margin * 2) - ) - - painter.save() - painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) - - # background. - bg_color = option.palette.window().color() - pen_color = option.palette.midlight().color().lighter(120) - if option.state & QtWidgets.QStyle.State_Selected: - bg_color = bg_color.lighter(120) - pen_color = pen_color.lighter(160) - - pen = QtGui.QPen(pen_color, 3.0) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.setBrush(QtGui.QBrush(bg_color)) - painter.drawRoundedRect(base_rect, - int(base_rect.height()/radius), - int(base_rect.width()/radius)) - - if option.state & QtWidgets.QStyle.StateFlag.State_Selected: - pen_color = option.palette.highlight().color() - else: - pen_color = option.palette.midlight().color().darker(130) - pen = QtGui.QPen(pen_color, 1.0) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - - sub_margin = 6 - sub_rect = QtCore.QRectF( - base_rect.x() + sub_margin, - base_rect.y() + sub_margin, - base_rect.width() - (sub_margin * 2), - base_rect.height() - (sub_margin * 2) - ) - painter.drawRoundedRect(sub_rect, - int(sub_rect.height() / radius), - int(sub_rect.width() / radius)) - - painter.setBrush(QtGui.QBrush(pen_color)) - edge_size = 2, sub_rect.height() - 6 - left_x = sub_rect.left() - right_x = sub_rect.right() - edge_size[0] - pos_y = sub_rect.center().y() - (edge_size[1] / 2) - - for pos_x in [left_x, right_x]: - painter.drawRect(QtCore.QRectF( - pos_x, pos_y, edge_size[0], edge_size[1] - )) - - # painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QBrush(bg_color)) - dot_size = 4 - left_x = sub_rect.left() - 1 - right_x = sub_rect.right() - (dot_size - 1) - pos_y = sub_rect.center().y() - (dot_size / 2) - for pos_x in [left_x, right_x]: - painter.drawEllipse(QtCore.QRectF( - pos_x, pos_y, dot_size, dot_size - )) - pos_x -= dot_size + 2 - - # text - pen_color = option.palette.text().color() - pen = QtGui.QPen(pen_color, 0.5) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - - font = painter.font() - font_metrics = QtGui.QFontMetrics(font) - item_text = item.text().replace(' ', '_') - if hasattr(font_metrics, 'horizontalAdvance'): - font_width = font_metrics.horizontalAdvance(item_text) - else: - font_width = font_metrics.width(item_text) - font_height = font_metrics.height() - text_rect = QtCore.QRectF( - sub_rect.center().x() - (font_width / 2), - sub_rect.center().y() - (font_height * 0.55), - font_width, font_height) - painter.drawText(text_rect, item.text()) - painter.restore() - - -class _NodesGridProxyModel(QtCore.QSortFilterProxyModel): - - def __init__(self, parent=None): - super(_NodesGridProxyModel, self).__init__(parent) - - def mimeData(self, indexes, p_int=None): - node_ids = [ - 'node:{}'.format(i.data(QtCore.Qt.ItemDataRole.ToolTipRole)) - for i in indexes - ] - node_urn = URN_SCHEME + ';'.join(node_ids) - mime_data = QtCore.QMimeData() - mime_data.setData(MIME_TYPE, QtCore.QByteArray(node_urn.encode())) - return mime_data - - -class NodesGridView(QtWidgets.QListView): - - def __init__(self, parent=None): - super(NodesGridView, self).__init__(parent) - self.setSelectionMode(self.SelectionMode.ExtendedSelection) - self.setUniformItemSizes(True) - self.setResizeMode(self.ResizeMode.Adjust) - self.setViewMode(self.ViewMode.IconMode) - self.setDragDropMode(self.DragDropMode.DragOnly) - self.setDragEnabled(True) - self.setMinimumSize(300, 100) - self.setSpacing(4) - - model = QtGui.QStandardItemModel() - proxy_model = _NodesGridProxyModel() - proxy_model.setSourceModel(model) - self.setModel(proxy_model) - self.setItemDelegate(_NodesGridDelegate(self)) - - def clear(self): - self.model().sourceModel().clear() - - def add_item(self, label, tooltip=''): - item = QtGui.QStandardItem(label) - item.setSizeHint(QtCore.QSize(130, 40)) - item.setToolTip(tooltip) - model = self.model().sourceModel() - model.appendRow(item) - - -class NodesPaletteWidget(QtWidgets.QWidget): - """ - The :class:`NodeGraphQt.NodesPaletteWidget` is a widget for displaying all - registered nodes from the node graph in a grid layout with this widget a - user can create nodes by dragging and dropping. - - | *Implemented on NodeGraphQt:* ``v0.1.7`` - - .. inheritance-diagram:: NodeGraphQt.NodesPaletteWidget - :parts: 1 - - .. image:: ../_images/nodes_palette.png - :width: 400px - - .. code-block:: python - :linenos: - - from NodeGraphQt import NodeGraph, NodesPaletteWidget - - # create node graph. - graph = NodeGraph() - - # create nodes palette widget. - nodes_palette = NodesPaletteWidget(parent=None, node_graph=graph) - nodes_palette.show() - - Args: - parent (QtWidgets.QWidget): parent of the new widget. - node_graph (NodeGraphQt.NodeGraph): node graph. - """ - - def __init__(self, parent=None, node_graph=None): - super(NodesPaletteWidget, self).__init__(parent) - self.setWindowTitle('Nodes') - - self._category_tabs = {} - self._custom_labels = {} - self._factory = node_graph.node_factory if node_graph else None - - self._tab_widget = QtWidgets.QTabWidget() - self._tab_widget.setMovable(True) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self._tab_widget) - - self._build_ui() - - # update the ui if new nodes are registered post init. - node_graph.nodes_registered.connect(self._on_nodes_registered) - - def __repr__(self): - return '<{} object at {}>'.format( - self.__class__.__name__, hex(id(self)) - ) - - def _on_nodes_registered(self, nodes): - """ - Slot function when a new node has been registered into the node graph. - - Args: - nodes (list[NodeObject]): node objects. - """ - node_types = defaultdict(list) - for node in nodes: - name = node.NODE_NAME - node_type = node.type_ - category = '.'.join(node_type.split('.')[:-1]) - node_types[category].append((node_type, name)) - - update_tabs = False - for category, nodes_list in node_types.items(): - if not update_tabs and category not in self._category_tabs: - update_tabs = True - grid_view = self._add_category_tab(category) - for node_id, node_name in nodes_list: - grid_view.add_item(node_name, node_id) - - if update_tabs: - self._update_tab_labels() - - def _update_tab_labels(self): - """ - Update the tab labels. - """ - tabs_idx = {self._tab_widget.tabText(x): x - for x in range(self._tab_widget.count())} - for category, label in self._custom_labels.items(): - if category in tabs_idx: - idx = tabs_idx[category] - self._tab_widget.setTabText(idx, label) - - def _build_ui(self): - """ - populate the ui - """ - node_types = defaultdict(list) - for name, node_ids in self._factory.names.items(): - for nid in node_ids: - category = '.'.join(nid.split('.')[:-1]) - node_types[category].append((nid, name)) - - for category, nodes_list in node_types.items(): - grid_view = self._add_category_tab(category) - for node_id, node_name in nodes_list: - grid_view.add_item(node_name, node_id) - - def _set_node_factory(self, factory): - """ - Set current node factory. - - Args: - factory (NodeFactory): node factory. - """ - self._factory = factory - - def _add_category_tab(self, category): - """ - Adds a new tab to the node palette widget. - - Args: - category (str): node identifier category eg. ``"nodes.widgets"`` - - Returns: - NodesGridView: nodes grid view widget. - """ - if category not in self._category_tabs: - grid_widget = NodesGridView(self) - self._tab_widget.addTab(grid_widget, category) - self._category_tabs[category] = grid_widget - return self._category_tabs[category] - - def set_category_label(self, category, label): - """ - Override tab label for a node category tab. - - Args: - category (str): node identifier category eg. ``"nodes.widgets"`` - label (str): custom display label. eg. ``"Node Widgets"`` - """ - if label in self._custom_labels.values(): - labels = {v: k for k, v in self._custom_labels.items()} - raise ValueError('label "{}" already in use for "{}"' - .format(label, labels[label])) - previous_label = self._custom_labels.get(category, '') - for idx in range(self._tab_widget.count()): - tab_text = self._tab_widget.tabText(idx) - if tab_text in [category, previous_label]: - self._tab_widget.setTabText(idx, label) - break - self._custom_labels[category] = label - - def tab_widget(self): - """ - Get the tab widget. - - Returns: - QtWidgets.QTabWidget: tab widget. - """ - return self._tab_widget - - def update(self): - """ - Update and refresh the node palette widget. - """ - for category, grid_view in self._category_tabs.items(): - grid_view.clear() - - node_types = defaultdict(list) - for name, node_ids in self._factory.names.items(): - for nid in node_ids: - category = '.'.join(nid.split('.')[:-1]) - node_types[category].append((nid, name)) - - for category, nodes_list in node_types.items(): - grid_view = self._category_tabs.get(category) - if not grid_view: - grid_view = self._add_category_tab(category) - - for node_id, node_name in nodes_list: - grid_view.add_item(node_name, node_id) - - self._update_tab_labels() diff --git a/cuegui/NodeGraphQt/custom_widgets/nodes_tree.py b/cuegui/NodeGraphQt/custom_widgets/nodes_tree.py deleted file mode 100644 index d6f73ae98..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/nodes_tree.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -from qtpy import QtWidgets, QtCore, QtGui - -from NodeGraphQt.constants import MIME_TYPE, URN_SCHEME - -TYPE_NODE = QtWidgets.QTreeWidgetItem.UserType + 1 -TYPE_CATEGORY = QtWidgets.QTreeWidgetItem.UserType + 2 - - -class _BaseNodeTreeItem(QtWidgets.QTreeWidgetItem): - - def __eq__(self, other): - """ - Workaround fix for QTreeWidgetItem "operator not implemented error". - see link: https://bugreports.qt.io/browse/PYSIDE-74 - """ - return id(self) == id(other) - - -class NodesTreeWidget(QtWidgets.QTreeWidget): - """ - The :class:`NodeGraphQt.NodesTreeWidget` is a widget for displaying all - registered nodes from the node graph with this widget a user can create - nodes by dragging and dropping. - - .. inheritance-diagram:: NodeGraphQt.NodesTreeWidget - :parts: 1 - :top-classes: PySide2.QtWidgets.QWidget - - .. image:: ../_images/nodes_tree.png - :width: 300px - - .. code-block:: python - :linenos: - - from NodeGraphQt import NodeGraph, NodesTreeWidget - - # create node graph. - graph = NodeGraph() - - # create node tree widget. - nodes_tree = NodesTreeWidget(parent=None, node_graph=graph) - nodes_tree.show() - - Args: - parent (QtWidgets.QWidget): parent of the new widget. - node_graph (NodeGraphQt.NodeGraph): node graph. - """ - - def __init__(self, parent=None, node_graph=None): - super(NodesTreeWidget, self).__init__(parent) - self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.DragOnly) - self.setSelectionMode(self.SelectionMode.ExtendedSelection) - self.setHeaderHidden(True) - self.setWindowTitle('Nodes') - - self._factory = node_graph.node_factory if node_graph else None - self._custom_labels = {} - self._category_items = {} - - self._build_tree() - - def __repr__(self): - return '<{} object at {}>'.format( - self.__class__.__name__, hex(id(self)) - ) - - def mimeData(self, items): - node_ids = ['node:{}'.format(i.toolTip(0)) for i in items] - node_urn = URN_SCHEME + ';'.join(node_ids) - mime_data = QtCore.QMimeData() - mime_data.setData(MIME_TYPE, QtCore.QByteArray(node_urn.encode())) - return mime_data - - def _build_tree(self): - """ - Populate the node tree. - """ - self.clear() - categories = set() - node_types = {} - for name, node_ids in self._factory.names.items(): - for nid in node_ids: - categories.add('.'.join(nid.split('.')[:-1])) - node_types[nid] = name - - self._category_items = {} - for category in sorted(categories): - if category in self._custom_labels.keys(): - label = self._custom_labels[category] - else: - label = '{}'.format(category) - cat_item = _BaseNodeTreeItem(self, [label], type=TYPE_CATEGORY) - cat_item.setFirstColumnSpanned(True) - cat_item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled) - cat_item.setSizeHint(0, QtCore.QSize(100, 26)) - self.addTopLevelItem(cat_item) - cat_item.setExpanded(True) - self._category_items[category] = cat_item - - for node_id, node_name in node_types.items(): - category = '.'.join(node_id.split('.')[:-1]) - category_item = self._category_items[category] - - item = _BaseNodeTreeItem(category_item, [node_name], type=TYPE_NODE) - item.setToolTip(0, node_id) - item.setSizeHint(0, QtCore.QSize(100, 26)) - - category_item.addChild(item) - - def _set_node_factory(self, factory): - """ - Set current node factory. - - Args: - factory (NodeFactory): node factory. - """ - self._factory = factory - - def set_category_label(self, category, label): - """ - Override the label for a node category root item. - - .. image:: ../_images/nodes_tree_category_label.png - :width: 70% - - Args: - category (str): node identifier category eg. ``"nodes.widgets"`` - label (str): custom display label. eg. ``"Node Widgets"`` - """ - self._custom_labels[category] = label - if category in self._category_items: - item = self._category_items[category] - item.setText(0, label) - - def update(self): - """ - Update and refresh the node tree widget. - """ - self._build_tree() diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/__init__.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py deleted file mode 100644 index e93750e28..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_color_picker.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/python -from qtpy import QtWidgets, QtCore, QtGui - -from .custom_widget_vectors import PropVector3, PropVector4 -from .prop_widgets_abstract import BaseProperty - - -class PropColorPickerRGB(BaseProperty): - """ - Color picker widget for a node property. - """ - - def __init__(self, parent=None): - super(PropColorPickerRGB, self).__init__(parent) - self._color = (0, 0, 0) - self._button = QtWidgets.QPushButton() - self._vector = PropVector3() - self._vector.set_steps([1, 10, 100]) - self._vector.set_data_type(int) - self._vector.set_value([0, 0, 0]) - self._vector.set_min(0) - self._vector.set_max(255) - self._update_color() - - self._button.clicked.connect(self._on_select_color) - self._vector.value_changed.connect(self._on_vector_changed) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._button, 0, QtCore.Qt.AlignmentFlag.AlignLeft) - layout.addWidget(self._vector, 1, QtCore.Qt.AlignmentFlag.AlignLeft) - - def _on_vector_changed(self, _, value): - self._color = tuple(value) - self._update_color() - self.value_changed.emit(self.get_name(), value) - - def _on_select_color(self): - current_color = QtGui.QColor(*self.get_value()) - color = QtWidgets.QColorDialog.getColor(current_color, self) - if color.isValid(): - self.set_value(color.getRgb()) - - def _update_vector(self): - self._vector.set_value(self._color) - - def _update_color(self): - c = [int(max(min(i, 255), 0)) for i in self._color] - hex_color = '#{0:02x}{1:02x}{2:02x}'.format(*c) - self._button.setStyleSheet( - ''' - QPushButton {{background-color: rgba({0}, {1}, {2}, 255);}} - QPushButton::hover {{background-color: rgba({0}, {1}, {2}, 200);}} - '''.format(*c) - ) - self._button.setToolTip( - 'rgb: {}\nhex: {}'.format(self._color[:3], hex_color) - ) - - def set_data_type(self, data_type): - """ - Sets the input line edit fields to either display in float or int. - - Args: - data_type(int or float): int or float data type object. - """ - self._vector.set_data_type(data_type) - - def get_value(self): - return self._color[:3] - - def set_value(self, value): - if value != self.get_value(): - self._color = value - self._update_color() - self._update_vector() - self.value_changed.emit(self.get_name(), value) - - -class PropColorPickerRGBA(PropColorPickerRGB): - """ - Color4 (rgba) picker widget for a node property. - """ - - def __init__(self, parent=None): - BaseProperty.__init__(self, parent) - self._color = (0, 0, 0, 255) - self._button = QtWidgets.QPushButton() - self._vector = PropVector4() - self._vector.set_steps([1, 10, 100]) - self._vector.set_data_type(int) - self._vector.set_value([0, 0, 0, 255]) - self._vector.set_min(0) - self._vector.set_max(255) - self._update_color() - - self._button.clicked.connect(self._on_select_color) - self._vector.value_changed.connect(self._on_vector_changed) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._button, 0, QtCore.Qt.AlignmentFlag.AlignLeft) - layout.addWidget(self._vector, 1, QtCore.Qt.AlignmentFlag.AlignLeft) - - def _update_color(self): - c = [int(max(min(i, 255), 0)) for i in self._color] - hex_color = '#{0:02x}{1:02x}{2:02x}{3:03x}'.format(*c) - self._button.setStyleSheet( - ''' - QPushButton {{background-color: rgba({0}, {1}, {2}, {3});}} - QPushButton::hover {{background-color: rgba({0}, {1}, {2}, {3});}} - '''.format(*c) - ) - self._button.setToolTip( - 'rgba: {}\nhex: {}'.format(self._color, hex_color) - ) - - def get_value(self): - return self._color[:4] diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py deleted file mode 100644 index b2b940963..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_file_paths.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/python -from qtpy import QtWidgets, QtCore - -from NodeGraphQt.widgets.dialogs import FileDialog -from .prop_widgets_abstract import BaseProperty - - -class PropFilePath(BaseProperty): - """ - Displays a node property as a "QFileDialog" open widget in the - PropertiesBin. - """ - - def __init__(self, parent=None): - super(PropFilePath, self).__init__(parent) - self._ledit = QtWidgets.QLineEdit() - self._ledit.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) - self._ledit.editingFinished.connect(self._on_value_change) - self._ledit.clearFocus() - - icon = self.style().standardIcon(QtWidgets.QStyle.StandardPixmap(21)) - _button = QtWidgets.QPushButton() - _button.setIcon(icon) - _button.clicked.connect(self._on_select_file) - - hbox = QtWidgets.QHBoxLayout(self) - hbox.setContentsMargins(0, 0, 0, 0) - hbox.addWidget(self._ledit) - hbox.addWidget(_button) - - self._ext = '*' - self._file_directory = None - - def _on_select_file(self): - file_path = FileDialog.getOpenFileName(self, - file_dir=self._file_directory, - ext_filter=self._ext) - file = file_path[0] or None - if file: - self.set_value(file) - - def _on_value_change(self, value=None): - if value is None: - value = self._ledit.text() - self.set_file_directory(value) - self.value_changed.emit(self.get_name(), value) - - def set_file_ext(self, ext=None): - self._ext = ext or '*' - - def set_file_directory(self, directory): - self._file_directory = directory - - def get_value(self): - return self._ledit.text() - - def set_value(self, value): - _value = str(value) - if _value != self.get_value(): - self._ledit.setText(_value) - self._on_value_change(_value) - - -class PropFileSavePath(PropFilePath): - """ - Displays a node property as a "QFileDialog" save widget in the - PropertiesBin. - """ - - def _on_select_file(self): - file_path = FileDialog.getSaveFileName(self, - file_dir=self._file_directory, - ext_filter=self._ext) - file = file_path[0] or None - if file: - self.set_value(file) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py deleted file mode 100644 index 17dd2fa70..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_slider.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/python -from qtpy import QtWidgets, QtCore - -from .prop_widgets_abstract import BaseProperty - - -class PropSlider(BaseProperty): - """ - Displays a node property as a "Slider" widget in the PropertiesBin - widget. - """ - - def __init__(self, parent=None, disable_scroll=True, realtime_update=False): - super(PropSlider, self).__init__(parent) - self._block = False - self._realtime_update = realtime_update - self._disable_scroll = disable_scroll - self._slider = QtWidgets.QSlider() - self._spinbox = QtWidgets.QSpinBox() - self._init() - self._init_signal_connections() - - def _init(self): - self._slider.setOrientation(QtCore.Qt.Orientation.Horizontal) - self._slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBelow) - self._slider.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Preferred) - self._spinbox.setButtonSymbols(QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons) - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._spinbox) - layout.addWidget(self._slider) - # store the original press event. - self._slider_mouse_press_event = self._slider.mousePressEvent - self._slider.mousePressEvent = self._on_slider_mouse_press - self._slider.mouseReleaseEvent = self._on_slider_mouse_release - - if self._disable_scroll: - self._slider.wheelEvent = lambda _: None - self._spinbox.wheelEvent = lambda _: None - - def _init_signal_connections(self): - self._spinbox.valueChanged.connect(self._on_spnbox_changed) - self._slider.valueChanged.connect(self._on_slider_changed) - - def _on_slider_mouse_press(self, event): - self._block = True - self._slider_mouse_press_event(event) - - def _on_slider_mouse_release(self, event): - if not self._realtime_update: - self.value_changed.emit(self.get_name(), self.get_value()) - self._block = False - - def _on_slider_changed(self, value): - self._spinbox.setValue(value) - if self._realtime_update: - self.value_changed.emit(self.get_name(), self.get_value()) - - def _on_spnbox_changed(self, value): - if value != self._slider.value(): - self._slider.setValue(value) - if not self._block: - self.value_changed.emit(self.get_name(), self.get_value()) - - def get_value(self): - return self._spinbox.value() - - def set_value(self, value): - if value != self.get_value(): - self._block = True - self._spinbox.setValue(value) - self.value_changed.emit(self.get_name(), value) - self._block = False - - def set_min(self, value=0): - self._spinbox.setMinimum(value) - self._slider.setMinimum(value) - - def set_max(self, value=0): - self._spinbox.setMaximum(value) - self._slider.setMaximum(value) - - -class QDoubleSlider(QtWidgets.QSlider): - double_value_changed = QtCore.Signal(float) - - def __init__(self, decimals=2, *args, **kargs): - super(QDoubleSlider, self).__init__(*args, **kargs) - self._multiplier = 10 ** decimals - - self.valueChanged.connect(self._on_value_change) - - def _on_value_change(self): - value = float(super(QDoubleSlider, self).value()) / self._multiplier - self.double_value_changed.emit(value) - - def value(self): - return float(super(QDoubleSlider, self).value()) / self._multiplier - - def setMinimum(self, value): - return super(QDoubleSlider, self).setMinimum(value * self._multiplier) - - def setMaximum(self, value): - return super(QDoubleSlider, self).setMaximum(value * self._multiplier) - - def setSingleStep(self, value): - return super(QDoubleSlider, self).setSingleStep(value * self._multiplier) - - def singleStep(self): - return float(super(QDoubleSlider, self).singleStep()) / self._multiplier - - def setValue(self, value): - super(QDoubleSlider, self).setValue(int(value * self._multiplier)) - - -class PropDoubleSlider(PropSlider): - def __init__(self, parent=None, decimals=2, disable_scroll=True, realtime_update=False): - # Do not initialize Propslider, just its parents - super(PropSlider, self).__init__(parent) - self._block = False - self._realtime_update = realtime_update - self._disable_scroll = disable_scroll - self._slider = QDoubleSlider(decimals=decimals) - self._spinbox = QtWidgets.QDoubleSpinBox() - self._init() - self._init_signal_connections() - - def _init_signal_connections(self): - self._spinbox.valueChanged.connect(self._on_spnbox_changed) - # Connect to double_value_changed instead valueChanged - self._slider.double_value_changed.connect(self._on_slider_changed) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py deleted file mode 100644 index 007c033ec..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_value_edit.py +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/python -import re - -from qtpy import QtWidgets, QtCore, QtGui - -_NUMB_REGEX = re.compile(r'^((?:\-)*\d+)*([\.,])*(\d+(?:[eE](?:[\-\+])*\d+)*)*') - - -class _NumberValueMenu(QtWidgets.QMenu): - - mouseMove = QtCore.Signal(object) - mouseRelease = QtCore.Signal(object) - stepChange = QtCore.Signal() - - def __init__(self, parent=None): - super(_NumberValueMenu, self).__init__(parent) - self.step = 1 - self.steps = [] - self.last_action = None - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - # re-implemented. - - def mousePressEvent(self, event): - """ - Disabling the mouse press event. - """ - return - - def mouseReleaseEvent(self, event): - """ - Additional functionality to emit signal. - """ - self.mouseRelease.emit(event) - super(_NumberValueMenu, self).mouseReleaseEvent(event) - - def mouseMoveEvent(self, event): - """ - Additional functionality to emit step changed signal. - """ - self.mouseMove.emit(event) - super(_NumberValueMenu, self).mouseMoveEvent(event) - action = self.actionAt(event.pos()) - if action: - if action is not self.last_action: - self.stepChange.emit() - self.last_action = action - self.step = action.step - elif self.last_action: - self.setActiveAction(self.last_action) - - def _add_step_action(self, step): - action = QtWidgets.QAction(str(step), self) - action.step = step - self.addAction(action) - - def set_steps(self, steps): - self.clear() - self.steps = steps - for step in steps: - self._add_step_action(step) - - def set_data_type(self, data_type): - if data_type is int: - new_steps = [] - for step in self.steps: - if '.' not in str(step): - new_steps.append(step) - self.set_steps(new_steps) - elif data_type is float: - self.set_steps(self.steps) - - -class _NumberValueEdit(QtWidgets.QLineEdit): - - value_changed = QtCore.Signal(object) - - def __init__(self, parent=None, data_type=float): - super(_NumberValueEdit, self).__init__(parent) - self.setToolTip('"MMB + Drag Left/Right" to change values.') - self.setText('0') - - self._MMB_STATE = False - self._previous_x = None - self._previous_value = None - self._step = 1 - self._speed = 0.05 - self._data_type = float - self._min = None - self._max = None - - self._menu = _NumberValueMenu() - self._menu.mouseMove.connect(self.mouseMoveEvent) - self._menu.mouseRelease.connect(self.mouseReleaseEvent) - self._menu.stepChange.connect(self._reset_previous_x) - - self.editingFinished.connect(self._on_editing_finished) - - self.set_data_type(data_type) - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - # re-implemented - - def mouseMoveEvent(self, event): - if self._MMB_STATE: - if self._previous_x is None: - self._previous_x = event.x() - self._previous_value = self.get_value() - else: - self._step = self._menu.step - delta = event.x() - self._previous_x - value = self._previous_value - value = value + int(delta * self._speed) * self._step - self.set_value(value) - self._on_mmb_mouse_move() - super(_NumberValueEdit, self).mouseMoveEvent(event) - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.MouseButton.MiddleButton: - self._MMB_STATE = True - self._reset_previous_x() - self._menu.exec_(QtGui.QCursor.pos()) - super(_NumberValueEdit, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - self._menu.close() - self._MMB_STATE = False - super(_NumberValueEdit, self).mouseReleaseEvent(event) - - def keyPressEvent(self, event): - super(_NumberValueEdit, self).keyPressEvent(event) - if event.key() == QtCore.Qt.Key.Key_Up: - return - elif event.key() == QtCore.Qt.Key.Key_Down: - return - - # private - - def _reset_previous_x(self): - self._previous_x = None - - def _on_mmb_mouse_move(self): - self.value_changed.emit(self.get_value()) - - def _on_editing_finished(self): - if self._data_type is float: - match = _NUMB_REGEX.match(self.text()) - if match: - val1, point, val2 = match.groups() - if point: - val1 = val1 or '0' - val2 = val2 or '0' - self.setText(val1 + point + val2) - self.value_changed.emit(self.get_value()) - - def _convert_text(self, text): - """ - Convert text to int or float. - - Args: - text (str): input text. - - Returns: - int or float: converted value. - """ - match = _NUMB_REGEX.match(text) - if match: - val1, _, val2 = match.groups() - val1 = val1 or '0' - val2 = val2 or '0' - value = float(val1 + '.' + val2) - else: - value = 0.0 - if self._data_type is int: - value = int(value) - return value - - # public - - def set_data_type(self, data_type): - """ - Sets the line edit to either display value in float or int. - - Args: - data_type(int or float): int or float data type object. - """ - self._data_type = data_type - if data_type is int: - regexp = QtCore.QRegularExpression(r'\d+') - validator = QtGui.QRegularExpressionValidator(regexp, self) - steps = [1, 10, 100, 1000] - self._min = None if self._min is None else int(self._min) - self._max = None if self._max is None else int(self._max) - elif data_type is float: - regexp = QtCore.QRegularExpression(r'\d+[\.,]\d+(?:[eE](?:[\-\+]|)\d+)*') - validator = QtGui.QRegularExpressionValidator(regexp, self) - steps = [0.001, 0.01, 0.1, 1] - self._min = None if self._min is None else float(self._min) - self._max = None if self._max is None else float(self._max) - - self.setValidator(validator) - if not self._menu.steps: - self._menu.set_steps(steps) - self._menu.set_data_type(data_type) - - def set_steps(self, steps=None): - """ - Sets the step items in the MMB context menu. - - Args: - steps (list[int] or list[float]): list of ints or floats. - """ - step_types = { - int: [1, 10, 100, 1000], - float: [0.001, 0.01, 0.1, 1] - } - steps = steps or step_types.get(self._data_type) - self._menu.set_steps(steps) - - def set_min(self, value=None): - """ - Set the minimum range for the input field. - - Args: - value (int or float): minimum range value. - """ - if self._data_type is int: - self._min = int(value) - elif self._data_type is float: - self._min = float(value) - else: - self._min = value - - def set_max(self, value=None): - """ - Set the maximum range for the input field. - - Args: - value (int or float): maximum range value. - """ - if self._data_type is int: - self._max = int(value) - elif self._data_type is float: - self._max = float(value) - else: - self._max = value - - def get_value(self): - value = self._convert_text(self.text()) - return value - - def set_value(self, value): - text = str(value) - converted = self._convert_text(text) - current = self.get_value() - if converted == current: - return - point = None - if isinstance(converted, float): - point = _NUMB_REGEX.match(str(value)).groups(2) - if self._min is not None and converted < self._min: - text = str(self._min) - if point and point not in text: - text = str(self._min).replace('.', point) - if self._max is not None and converted > self._max: - text = str(self._max) - if point and point not in text: - text = text.replace('.', point) - self.setText(text) - - -class IntValueEdit(_NumberValueEdit): - - def __init__(self, parent=None): - super(IntValueEdit, self).__init__(parent, data_type=int) - - -class FloatValueEdit(_NumberValueEdit): - - def __init__(self, parent=None): - super(FloatValueEdit, self).__init__(parent, data_type=float) - - -if __name__ == '__main__': - app = QtWidgets.QApplication([]) - - int_edit = IntValueEdit() - int_edit.set_steps([1, 10]) - float_edit = FloatValueEdit() - - widget = QtWidgets.QWidget() - layout = QtWidgets.QVBoxLayout(widget) - layout.addWidget(int_edit) - layout.addWidget(float_edit) - widget.show() - - app.exec_() diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py deleted file mode 100644 index 3c1d918ec..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/custom_widget_vectors.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/python -from qtpy import QtWidgets - -from .custom_widget_value_edit import _NumberValueEdit -from .prop_widgets_abstract import BaseProperty - - -class _PropVector(BaseProperty): - """ - Base widget for the PropVector widgets. - """ - - def __init__(self, parent=None, fields=0): - super(_PropVector, self).__init__(parent) - self._value = [] - self._items = [] - self._can_emit = True - - layout = QtWidgets.QHBoxLayout(self) - layout.setSpacing(2) - layout.setContentsMargins(0, 0, 0, 0) - for i in range(fields): - self._add_item(i) - - def _add_item(self, index): - _ledit = _NumberValueEdit() - _ledit.index = index - _ledit.value_changed.connect( - lambda: self._on_value_change(_ledit.get_value(), _ledit.index) - ) - - self.layout().addWidget(_ledit) - self._value.append(0.0) - self._items.append(_ledit) - - def _on_value_change(self, value=None, index=None): - if self._can_emit: - if index is not None: - self._value = list(self._value) - self._value[index] = value - self.value_changed.emit(self.get_name(), self._value) - - def _update_items(self): - if not isinstance(self._value, (list, tuple)): - raise TypeError('Value "{}" must be either list or tuple.' - .format(self._value)) - for index, value in enumerate(self._value): - if (index + 1) > len(self._items): - continue - if self._items[index].get_value() != value: - self._items[index].set_value(value) - - def set_data_type(self, data_type): - """ - Sets the input line edit fields to either display in float or int. - - Args: - data_type(int or float): int or float data type object. - """ - for item in self._items: - item.set_data_type(data_type) - - def set_steps(self, steps): - """ - Sets the step items in the MMB context menu. - - Args: - steps (list[int] or list[float]): list of ints or floats. - """ - for item in self._items: - item.set_steps(steps) - - def set_min(self, value): - """ - Set the minimum range for the input fields. - - Args: - value (int or float): minimum range value. - """ - for item in self._items: - item.set_min(value) - - def set_max(self, value): - """ - Set the maximum range for the input fields. - - Args: - value (int or float): maximum range value. - """ - for item in self._items: - item.set_max(value) - - def get_value(self): - return self._value - - def set_value(self, value=None): - if value != self.get_value(): - self._value = value - self._can_emit = False - self._update_items() - self._can_emit = True - self._on_value_change() - - -class PropVector2(_PropVector): - """ - Displays a node property as a "Vector2" widget in the PropertiesBin - widget. - - Useful for display X,Y data. - """ - - def __init__(self, parent=None): - super(PropVector2, self).__init__(parent, 2) - - -class PropVector3(_PropVector): - """ - Displays a node property as a "Vector3" widget in the PropertiesBin - widget. - - Useful for displaying x,y,z data. - """ - - def __init__(self, parent=None): - super(PropVector3, self).__init__(parent, 3) - - -class PropVector4(_PropVector): - """ - Displays a node property as a "Vector4" widget in the PropertiesBin - widget. - - Useful for display r,g,b,a data. - """ - - def __init__(self, parent=None): - super(PropVector4, self).__init__(parent, 4) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py deleted file mode 100644 index d4c175fcf..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_factory.py +++ /dev/null @@ -1,60 +0,0 @@ -from NodeGraphQt.constants import NodePropWidgetEnum -from .custom_widget_color_picker import PropColorPickerRGB, PropColorPickerRGBA -from .custom_widget_file_paths import PropFilePath, PropFileSavePath -from .custom_widget_slider import PropSlider, PropDoubleSlider -from .custom_widget_value_edit import FloatValueEdit, IntValueEdit -from .custom_widget_vectors import PropVector2, PropVector3, PropVector4 -from .prop_widgets_base import ( - PropLabel, - PropLineEdit, - PropTextEdit, - PropComboBox, - PropCheckBox, - PropSpinBox, - PropDoubleSpinBox -) - - -class NodePropertyWidgetFactory(object): - """ - Node property widget factory for mapping the corresponding property widget - to the Properties bin. - """ - - def __init__(self): - self._widget_mapping = { - NodePropWidgetEnum.HIDDEN.value: None, - # base widgets. - NodePropWidgetEnum.QLABEL.value: PropLabel, - NodePropWidgetEnum.QLINE_EDIT.value: PropLineEdit, - NodePropWidgetEnum.QTEXT_EDIT.value: PropTextEdit, - NodePropWidgetEnum.QCOMBO_BOX.value: PropComboBox, - NodePropWidgetEnum.QCHECK_BOX.value: PropCheckBox, - NodePropWidgetEnum.QSPIN_BOX.value: PropSpinBox, - NodePropWidgetEnum.QDOUBLESPIN_BOX.value: PropDoubleSpinBox, - # custom widgets. - NodePropWidgetEnum.COLOR_PICKER.value: PropColorPickerRGB, - NodePropWidgetEnum.COLOR4_PICKER.value: PropColorPickerRGBA, - NodePropWidgetEnum.SLIDER.value: PropSlider, - NodePropWidgetEnum.DOUBLE_SLIDER.value: PropDoubleSlider, - NodePropWidgetEnum.FILE_OPEN.value: PropFilePath, - NodePropWidgetEnum.FILE_SAVE.value: PropFileSavePath, - NodePropWidgetEnum.VECTOR2.value: PropVector2, - NodePropWidgetEnum.VECTOR3.value: PropVector3, - NodePropWidgetEnum.VECTOR4.value: PropVector4, - NodePropWidgetEnum.FLOAT.value: FloatValueEdit, - NodePropWidgetEnum.INT.value: IntValueEdit, - } - - def get_widget(self, widget_type=NodePropWidgetEnum.HIDDEN.value): - """ - Return a new instance of a node property widget. - - Args: - widget_type (int): widget type index. - - Returns: - BaseProperty: node property widget. - """ - if widget_type in self._widget_mapping: - return self._widget_mapping[widget_type]() diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py deleted file mode 100644 index 086a78a5b..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/node_property_widgets.py +++ /dev/null @@ -1,873 +0,0 @@ -#!/usr/bin/python -from collections import defaultdict - -from qtpy import QtWidgets, QtCore, QtGui - -from .node_property_factory import NodePropertyWidgetFactory -from .prop_widgets_base import PropLineEdit - - -class _PropertiesDelegate(QtWidgets.QStyledItemDelegate): - - def paint(self, painter, option, index): - """ - Args: - painter (QtGui.QPainter): - option (QtGui.QStyleOptionViewItem): - index (QtCore.QModelIndex): - """ - painter.save() - painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, False) - painter.setPen(QtCore.Qt.NoPen) - - # draw background. - bg_clr = option.palette.base().color() - painter.setBrush(QtGui.QBrush(bg_clr)) - painter.drawRect(option.rect) - - # draw border. - border_width = 1 - if option.state & QtWidgets.QStyle.State_Selected: - bdr_clr = option.palette.highlight().color() - painter.setPen(QtGui.QPen(bdr_clr, 1.5)) - else: - bdr_clr = option.palette.alternateBase().color() - painter.setPen(QtGui.QPen(bdr_clr, 1)) - - painter.setBrush(QtCore.Qt.NoBrush) - painter.drawRect(QtCore.QRect( - option.rect.x() + border_width, - option.rect.y() + border_width, - option.rect.width() - (border_width * 2), - option.rect.height() - (border_width * 2)) - ) - painter.restore() - - -class _PropertiesList(QtWidgets.QTableWidget): - - def __init__(self, parent=None): - super(_PropertiesList, self).__init__(parent) - self.setItemDelegate(_PropertiesDelegate()) - self.setColumnCount(1) - self.setShowGrid(False) - self.verticalHeader().hide() - self.horizontalHeader().hide() - - QtWidgets.QHeaderView.setSectionResizeMode( - self.verticalHeader(), QtWidgets.QHeaderView.ResizeMode.ResizeToContents - ) - QtWidgets.QHeaderView.setSectionResizeMode( - self.horizontalHeader(), 0, QtWidgets.QHeaderView.ResizeMode.Stretch - ) - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) - - def wheelEvent(self, event): - """ - Args: - event (QtGui.QWheelEvent): - """ - delta = event.angleDelta().y() * 0.2 - self.verticalScrollBar().setValue( - self.verticalScrollBar().value() - delta - ) - - -class _PropertiesContainer(QtWidgets.QWidget): - """ - Node properties container widget that displays nodes properties under - a tab in the ``NodePropWidget`` widget. - """ - - def __init__(self, parent=None): - super(_PropertiesContainer, self).__init__(parent) - self.__layout = QtWidgets.QGridLayout() - self.__layout.setColumnStretch(1, 1) - self.__layout.setSpacing(6) - - layout = QtWidgets.QVBoxLayout(self) - layout.setAlignment(QtCore.Qt.AlignTop) - layout.addLayout(self.__layout) - - self.__property_widgets = {} - - def __repr__(self): - return '<{} object at {}>'.format( - self.__class__.__name__, hex(id(self)) - ) - - def add_widget(self, name, widget, value, label=None, tooltip=None): - """ - Add a property widget to the window. - - Args: - name (str): property name to be displayed. - widget (BaseProperty): property widget. - value (object): property value. - label (str): custom label to display. - tooltip (str): custom tooltip. - """ - label = label or name - label_widget = QtWidgets.QLabel(label) - if tooltip: - widget.setToolTip('{}\n{}'.format(name, tooltip)) - label_widget.setToolTip('{}\n{}'.format(name, tooltip)) - else: - widget.setToolTip(name) - label_widget.setToolTip(name) - widget.set_value(value) - row = self.__layout.rowCount() - if row > 0: - row += 1 - - label_flags = QtCore.Qt.AlignmentFlag.AlignCenter | QtCore.Qt.AlignmentFlag.AlignRight - if widget.__class__.__name__ == 'PropTextEdit': - label_flags = label_flags | QtCore.Qt.AlignmentFlag.AlignTop - - self.__layout.addWidget(label_widget, row, 0, label_flags) - self.__layout.addWidget(widget, row, 1) - self.__property_widgets[name] = widget - - def get_widget(self, name): - """ - Returns the property widget from the name. - - Args: - name (str): property name. - - Returns: - QtWidgets.QWidget: property widget. - """ - return self.__property_widgets.get(name) - - def get_all_widgets(self): - """ - Returns the node property widgets. - - Returns: - dict: {name: widget} - """ - return self.__property_widgets - - -class _PortConnectionsContainer(QtWidgets.QWidget): - """ - Port connection container widget that displays node ports and connections - under a tab in the ``NodePropWidget`` widget. - """ - - def __init__(self, parent=None, node=None): - super(_PortConnectionsContainer, self).__init__(parent) - self._node = node - self._ports = {} - - self.input_group, self.input_tree = self._build_tree_group( - 'Input Ports' - ) - self.input_group.setToolTip('Display input port connections') - for _, port in node.inputs().items(): - self._build_row(self.input_tree, port) - for col in range(self.input_tree.columnCount()): - self.input_tree.resizeColumnToContents(col) - - self.output_group, self.output_tree = self._build_tree_group( - 'Output Ports' - ) - self.output_group.setToolTip('Display output port connections') - for _, port in node.outputs().items(): - self._build_row(self.output_tree, port) - for col in range(self.output_tree.columnCount()): - self.output_tree.resizeColumnToContents(col) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.input_group) - layout.addWidget(self.output_group) - layout.addStretch() - - self.input_group.setChecked(False) - self.input_tree.setVisible(False) - self.output_group.setChecked(False) - self.output_tree.setVisible(False) - - def __repr__(self): - return '<{} object at {}>'.format( - self.__class__.__name__, hex(id(self)) - ) - - @staticmethod - def _build_tree_group(title): - """ - Build the ports group box and ports tree widget. - - Args: - title (str): group box title. - - Returns: - tuple(QtWidgets.QGroupBox, QtWidgets.QTreeWidget): widgets. - """ - group_box = QtWidgets.QGroupBox() - group_box.setMaximumHeight(200) - group_box.setCheckable(True) - group_box.setChecked(True) - group_box.setTitle(title) - group_box.setLayout(QtWidgets.QVBoxLayout()) - - headers = ['Locked', 'Name', 'Connections', ''] - tree_widget = QtWidgets.QTreeWidget() - tree_widget.setColumnCount(len(headers)) - tree_widget.setHeaderLabels(headers) - tree_widget.setHeaderHidden(False) - tree_widget.header().setStretchLastSection(False) - QtWidgets.QHeaderView.setSectionResizeMode( - tree_widget.header(), 2, QtWidgets.QHeaderView.Stretch - ) - - group_box.layout().addWidget(tree_widget) - - return group_box, tree_widget - - def _build_row(self, tree, port): - """ - Builds a new row in the parent ports tree widget. - - Args: - tree (QtWidgets.QTreeWidget): parent port tree widget. - port (NodeGraphQt.Port): port object. - """ - item = QtWidgets.QTreeWidgetItem(tree) - item.setFlags(item.flags() & ~QtCore.Qt.ItemIsSelectable) - item.setText(1, port.name()) - item.setToolTip(0, 'Lock Port') - item.setToolTip(1, 'Port Name') - item.setToolTip(2, 'Select connected port.') - item.setToolTip(3, 'Center on connected port node.') - - # TODO: will need to update this checkbox lock logic to work with - # the undo/redo functionality. - lock_chb = QtWidgets.QCheckBox() - lock_chb.setChecked(port.locked()) - lock_chb.clicked.connect(lambda x: port.set_locked(x)) - tree.setItemWidget(item, 0, lock_chb) - - combo = QtWidgets.QComboBox() - for cp in port.connected_ports(): - item_name = '{} : "{}"'.format(cp.name(), cp.node().name()) - self._ports[item_name] = cp - combo.addItem(item_name) - tree.setItemWidget(item, 2, combo) - - focus_btn = QtWidgets.QPushButton() - focus_btn.setIcon(QtGui.QIcon( - tree.style().standardPixmap(QtWidgets.QStyle.SP_DialogYesButton) - )) - focus_btn.clicked.connect( - lambda: self._on_focus_to_node(self._ports.get(combo.currentText())) - ) - tree.setItemWidget(item, 3, focus_btn) - - def _on_focus_to_node(self, port): - """ - Slot function emits the node is of the connected port. - - Args: - port (NodeGraphQt.Port): connected port. - """ - if port: - node = port.node() - node.graph.center_on([node]) - node.graph.clear_selection() - node.set_selected(True) - - def set_lock_controls_disable(self, disable=False): - """ - Enable/Disable port lock column widgets. - - Args: - disable (bool): true to disable checkbox. - """ - for r in range(self.input_tree.topLevelItemCount()): - item = self.input_tree.topLevelItem(r) - chb_widget = self.input_tree.itemWidget(item, 0) - chb_widget.setDisabled(disable) - for r in range(self.output_tree.topLevelItemCount()): - item = self.output_tree.topLevelItem(r) - chb_widget = self.output_tree.itemWidget(item, 0) - chb_widget.setDisabled(disable) - - -class NodePropEditorWidget(QtWidgets.QWidget): - """ - Node properties editor widget for display a Node object. - - Args: - parent (QtWidgets.QWidget): parent object. - node (NodeGraphQt.NodeObject): node. - """ - - #: signal (node_id, prop_name, prop_value) - property_changed = QtCore.Signal(str, str, object) - property_closed = QtCore.Signal(str) - - def __init__(self, parent=None, node=None): - super(NodePropEditorWidget, self).__init__(parent) - self.__node_id = node.id - self.__tab_windows = {} - self.__tab = QtWidgets.QTabWidget() - - close_btn = QtWidgets.QPushButton() - close_btn.setIcon(QtGui.QIcon( - self.style().standardPixmap( - QtWidgets.QStyle.StandardPixmap.SP_DialogCloseButton - ) - )) - close_btn.setMaximumWidth(40) - close_btn.setToolTip('close property') - close_btn.clicked.connect(self._on_close) - - self.name_wgt = PropLineEdit() - self.name_wgt.set_name('name') - self.name_wgt.setToolTip('name\nSet the node name.') - self.name_wgt.set_value(node.name()) - self.name_wgt.value_changed.connect(self._on_property_changed) - - self.type_wgt = QtWidgets.QLabel(node.type_) - self.type_wgt.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) - self.type_wgt.setToolTip( - 'type_\nNode type identifier followed by the class name.' - ) - font = self.type_wgt.font() - font.setPointSize(10) - self.type_wgt.setFont(font) - - name_layout = QtWidgets.QHBoxLayout() - name_layout.setContentsMargins(0, 0, 0, 0) - name_layout.addWidget(QtWidgets.QLabel('name')) - name_layout.addWidget(self.name_wgt) - name_layout.addWidget(close_btn) - layout = QtWidgets.QVBoxLayout(self) - layout.setSpacing(4) - layout.addLayout(name_layout) - layout.addWidget(self.__tab) - layout.addWidget(self.type_wgt) - - self._port_connections = self._read_node(node) - - def __repr__(self): - return '<{} object at {}>'.format( - self.__class__.__name__, hex(id(self)) - ) - - def _on_close(self): - """ - called by the close button. - """ - self.property_closed.emit(self.__node_id) - - def _on_property_changed(self, name, value): - """ - slot function called when a property widget has changed. - - Args: - name (str): property name. - value (object): new value. - """ - self.property_changed.emit(self.__node_id, name, value) - - def _read_node(self, node): - """ - Populate widget from a node. - - Args: - node (NodeGraphQt.BaseNode): node class. - - Returns: - _PortConnectionsContainer: ports container widget. - """ - model = node.model - graph_model = node.graph.model - - common_props = graph_model.get_node_common_properties(node.type_) - - # sort tabs and properties. - tab_mapping = defaultdict(list) - for prop_name, prop_val in model.custom_properties.items(): - tab_name = model.get_tab_name(prop_name) - tab_mapping[tab_name].append((prop_name, prop_val)) - - # add tabs. - reserved_tabs = ['Node', 'Ports'] - for tab in sorted(tab_mapping.keys()): - if tab in reserved_tabs: - print('tab name "{}" is reserved by the "NodePropWidget" ' - 'please use a different tab name.') - continue - self.add_tab(tab) - - # property widget factory. - widget_factory = NodePropertyWidgetFactory() - - # populate tab properties. - for tab in sorted(tab_mapping.keys()): - prop_window = self.__tab_windows[tab] - for prop_name, value in tab_mapping[tab]: - wid_type = model.get_widget_type(prop_name) - if wid_type == 0: - continue - - widget = widget_factory.get_widget(wid_type) - widget.set_name(prop_name) - - tooltip = None - if prop_name in common_props.keys(): - if 'items' in common_props[prop_name].keys(): - widget.set_items(common_props[prop_name]['items']) - if 'range' in common_props[prop_name].keys(): - prop_range = common_props[prop_name]['range'] - widget.set_min(prop_range[0]) - widget.set_max(prop_range[1]) - if 'tooltip' in common_props[prop_name].keys(): - tooltip = common_props[prop_name]['tooltip'] - prop_window.add_widget( - name=prop_name, - widget=widget, - value=value, - label=prop_name.replace('_', ' '), - tooltip=tooltip - ) - widget.value_changed.connect(self._on_property_changed) - - # add "Node" tab properties. (default props) - self.add_tab('Node') - default_props = { - 'color': 'Node base color.', - 'text_color': 'Node text color.', - 'border_color': 'Node border color.', - 'disabled': 'Disable/Enable node state.', - 'id': 'Unique identifier string to the node.' - } - prop_window = self.__tab_windows['Node'] - for prop_name, tooltip in default_props.items(): - wid_type = model.get_widget_type(prop_name) - widget = widget_factory.get_widget(wid_type) - widget.set_name(prop_name) - prop_window.add_widget( - name=prop_name, - widget=widget, - value=model.get_property(prop_name), - label=prop_name.replace('_', ' '), - tooltip=tooltip - ) - - widget.value_changed.connect(self._on_property_changed) - - self.type_wgt.setText(model.get_property('type_') or '') - - # add "ports" tab connections. - ports_container = None - if node.inputs() or node.outputs(): - ports_container = _PortConnectionsContainer(self, node=node) - self.__tab.addTab(ports_container, 'Ports') - - # hide/remove empty tabs with no property widgets. - tab_index = { - self.__tab.tabText(x): x for x in range(self.__tab.count()) - } - current_idx = None - for tab_name, prop_window in self.__tab_windows.items(): - prop_widgets = prop_window.get_all_widgets() - if not prop_widgets: - # I prefer to hide the tab but in older version of pyside this - # attribute doesn't exist we'll just remove. - if hasattr(self.__tab, 'setTabVisible'): - self.__tab.setTabVisible(tab_index[tab_name], False) - else: - self.__tab.removeTab(tab_index[tab_name]) - continue - if current_idx is None: - current_idx = tab_index[tab_name] - - self.__tab.setCurrentIndex(current_idx) - - return ports_container - - def node_id(self): - """ - Returns the node id linked to the widget. - - Returns: - str: node id - """ - return self.__node_id - - def add_widget(self, name, widget, tab='Properties'): - """ - add new node property widget. - - Args: - name (str): property name. - widget (BaseProperty): property widget. - tab (str): tab name. - """ - if tab not in self._widgets.keys(): - tab = 'Properties' - window = self.__tab_windows[tab] - window.add_widget(name, widget) - widget.value_changed.connect(self._on_property_changed) - - def add_tab(self, name): - """ - add a new tab. - - Args: - name (str): tab name. - - Returns: - PropListWidget: tab child widget. - """ - if name in self.__tab_windows.keys(): - raise AssertionError('Tab name {} already taken!'.format(name)) - self.__tab_windows[name] = _PropertiesContainer(self) - self.__tab.addTab(self.__tab_windows[name], name) - return self.__tab_windows[name] - - def get_tab_widget(self): - """ - Returns the underlying tab widget. - - Returns: - QtWidgets.QTabWidget: tab widget. - """ - return self.__tab - - def get_widget(self, name): - """ - get property widget. - - Args: - name (str): property name. - - Returns: - NodeGraphQt.custom_widgets.properties_bin.prop_widgets_abstract.BaseProperty: property widget. - """ - if name == 'name': - return self.name_wgt - for prop_win in self.__tab_windows.values(): - widget = prop_win.get_widget(name) - if widget: - return widget - - def get_all_property_widgets(self): - """ - get all the node property widgets. - - Returns: - list[BaseProperty]: property widgets. - """ - widgets = [self.name_wgt] - for prop_win in self.__tab_windows.values(): - for widget in prop_win.get_all_widgets().values(): - widgets.append(widget) - return widgets - - def get_port_connection_widget(self): - """ - Returns the ports connections container widget. - - Returns: - _PortConnectionsContainer: port container widget. - """ - return self._port_connections - - def set_port_lock_widgets_disabled(self, disabled=True): - """ - Enable/Disable port lock column widgets. - - Args: - disabled (bool): true to disable checkbox. - """ - self._port_connections.set_lock_controls_disable(disabled) - - -class PropertiesBinWidget(QtWidgets.QWidget): - """ - The :class:`NodeGraphQt.PropertiesBinWidget` is a list widget for displaying - and editing a nodes properties. - - .. inheritance-diagram:: NodeGraphQt.PropertiesBinWidget - :parts: 1 - - .. image:: ../_images/prop_bin.png - :width: 950px - - .. code-block:: python - :linenos: - - from NodeGraphQt import NodeGraph, PropertiesBinWidget - - # create node graph. - graph = NodeGraph() - - # create properties bin widget. - properties_bin = PropertiesBinWidget(parent=None, node_graph=graph) - properties_bin.show() - - See Also: - :meth:`NodeGraphQt.BaseNode.add_custom_widget`, - :meth:`NodeGraphQt.NodeObject.create_property`, - :attr:`NodeGraphQt.constants.NodePropWidgetEnum` - - Args: - parent (QtWidgets.QWidget): parent of the new widget. - node_graph (NodeGraphQt.NodeGraph): node graph. - """ - - #: Signal emitted (node_id, prop_name, prop_value) - property_changed = QtCore.Signal(str, str, object) - - def __init__(self, parent=None, node_graph=None): - super(PropertiesBinWidget, self).__init__(parent) - self.setWindowTitle('Properties Bin') - self._prop_list = _PropertiesList() - self._limit = QtWidgets.QSpinBox() - self._limit.setToolTip('Set display nodes limit.') - self._limit.setMaximum(10) - self._limit.setMinimum(0) - self._limit.setValue(2) - self._limit.valueChanged.connect(self.__on_limit_changed) - self.resize(450, 400) - - # this attribute to block signals if for the "on_property_changed" signal - # in case devs that don't implement the ".prop_widgets_abstract.BaseProperty" - # widget properly to prevent an infinite loop. - self._block_signal = False - - self._lock = False - self._btn_lock = QtWidgets.QPushButton('Lock') - self._btn_lock.setToolTip( - 'Lock the properties bin prevent nodes from being loaded.') - self._btn_lock.clicked.connect(self.lock_bin) - - btn_clr = QtWidgets.QPushButton('Clear') - btn_clr.setToolTip('Clear the properties bin.') - btn_clr.clicked.connect(self.clear_bin) - - top_layout = QtWidgets.QHBoxLayout() - top_layout.setSpacing(2) - top_layout.addWidget(self._limit) - top_layout.addStretch(1) - top_layout.addWidget(self._btn_lock) - top_layout.addWidget(btn_clr) - - layout = QtWidgets.QVBoxLayout(self) - layout.addLayout(top_layout) - layout.addWidget(self._prop_list, 1) - - # wire up node graph. - node_graph.add_properties_bin(self) - node_graph.node_double_clicked.connect(self.add_node) - node_graph.nodes_deleted.connect(self.__on_nodes_deleted) - node_graph.property_changed.connect(self.__on_graph_property_changed) - - def __repr__(self): - return '<{} object at {}>'.format( - self.__class__.__name__, hex(id(self)) - ) - - def __on_port_tree_visible_changed(self, node_id, visible, tree_widget): - """ - Triggered when the visibility of the port tree widget changes we - resize the property list table row. - - Args: - node_id (str): node id. - visible (bool): visibility state. - tree_widget (QtWidgets.QTreeWidget): ports tree widget. - """ - items = self._prop_list.findItems(node_id, QtCore.Qt.MatchExactly) - if items: - tree_widget.setVisible(visible) - widget = self._prop_list.cellWidget(items[0].row(), 0) - widget.adjustSize() - QtWidgets.QHeaderView.setSectionResizeMode( - self._prop_list.verticalHeader(), - QtWidgets.QHeaderView.ResizeToContents - ) - - def __on_prop_close(self, node_id): - """ - Triggered when a node property widget is requested to be removed from - the property list widget. - - Args: - node_id (str): node id. - """ - items = self._prop_list.findItems(node_id, QtCore.Qt.MatchFlag.MatchExactly) - [self._prop_list.removeRow(i.row()) for i in items] - - def __on_limit_changed(self, value): - """ - Sets the property list widget limit. - - Args: - value (int): limit value. - """ - rows = self._prop_list.rowCount() - if rows > value: - self._prop_list.removeRow(rows - 1) - - def __on_nodes_deleted(self, nodes): - """ - Slot function when a node has been deleted. - - Args: - nodes (list[str]): list of node ids. - """ - [self.__on_prop_close(n) for n in nodes] - - def __on_graph_property_changed(self, node, prop_name, prop_value): - """ - Slot function that updates the property bin from the node graph signal. - - Args: - node (NodeGraphQt.NodeObject): - prop_name (str): node property name. - prop_value (object): node property value. - """ - properties_widget = self.get_property_editor_widget(node) - if not properties_widget: - return - - property_widget = properties_widget.get_widget(prop_name) - - if property_widget and prop_value != property_widget.get_value(): - self._block_signal = True - property_widget.set_value(prop_value) - self._block_signal = False - - def __on_property_widget_changed(self, node_id, prop_name, prop_value): - """ - Slot function triggered when a property widget value has changed. - - Args: - node_id (str): node id. - prop_name (str): node property name. - prop_value (object): node property value. - """ - if not self._block_signal: - self.property_changed.emit(node_id, prop_name, prop_value) - - def create_property_editor(self, node): - """ - Creates a new property editor widget from the provided node. - - (re-implement for displaying custom node property editor widget.) - - Args: - node (NodeGraphQt.NodeObject): node object. - - Returns: - NodePropEditorWidget: property editor widget. - """ - return NodePropEditorWidget(node=node) - - def limit(self): - """ - Returns the limit for how many nodes can be loaded into the bin. - - Returns: - int: node limit. - """ - return int(self._limit.value()) - - def set_limit(self, limit): - """ - Set limit of nodes to display. - - Args: - limit (int): node limit. - """ - self._limit.setValue(limit) - - def add_node(self, node): - """ - Add node to the properties bin. - - Args: - node (NodeGraphQt.NodeObject): node object. - """ - if self.limit() == 0 or self._lock: - return - - rows = self._prop_list.rowCount() - 1 - if rows >= self.limit(): - self._prop_list.removeRow(rows - 1) - - itm_find = self._prop_list.findItems(node.id, QtCore.Qt.MatchFlag.MatchExactly) - if itm_find: - self._prop_list.removeRow(itm_find[0].row()) - - self._prop_list.insertRow(0) - - prop_widget = self.create_property_editor(node=node) - prop_widget.property_closed.connect(self.__on_prop_close) - prop_widget.property_changed.connect(self.__on_property_widget_changed) - port_connections = prop_widget.get_port_connection_widget() - if port_connections: - port_connections.input_group.clicked.connect( - lambda v: self.__on_port_tree_visible_changed( - prop_widget.node_id(), v, port_connections.input_tree - ) - ) - port_connections.output_group.clicked.connect( - lambda v: self.__on_port_tree_visible_changed( - prop_widget.node_id(), v, port_connections.output_tree - ) - ) - - self._prop_list.setCellWidget(0, 0, prop_widget) - - item = QtWidgets.QTableWidgetItem(node.id) - self._prop_list.setItem(0, 0, item) - self._prop_list.selectRow(0) - - def remove_node(self, node): - """ - Remove node from the properties bin. - - Args: - node (str or NodeGraphQt.BaseNode): node id or node object. - """ - node_id = node if isinstance(node, str) else node.id - self.__on_prop_close(node_id) - - def lock_bin(self): - """ - Lock/UnLock the properties bin. - """ - self._lock = not self._lock - if self._lock: - self._btn_lock.setText('UnLock') - else: - self._btn_lock.setText('Lock') - - def clear_bin(self): - """ - Clear the properties bin. - """ - self._prop_list.setRowCount(0) - - def get_property_editor_widget(self, node): - """ - Returns the node property editor widget. - - Args: - node (str or NodeGraphQt.NodeObject): node id or node object. - - Returns: - NodePropEditorWidget: node property editor widget. - """ - node_id = node if isinstance(node, str) else node.id - itm_find = self._prop_list.findItems(node_id, QtCore.Qt.MatchFlag.MatchExactly) - if itm_find: - item = itm_find[0] - return self._prop_list.cellWidget(item.row(), 0) diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py deleted file mode 100644 index 5d60dc5da..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_abstract.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/python -from qtpy import QtWidgets, QtCore - - -class BaseProperty(QtWidgets.QWidget): - """ - Base class for a custom node property widget to be displayed in the - PropertiesBin widget. - - Inherits from: :class:`PySide2.QtWidgets.QWidget` - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(BaseProperty, self).__init__(parent) - self._name = None - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def get_name(self): - """ - Returns: - str: property name matching the node property. - """ - return self._name - - def set_name(self, name): - """ - Args: - name (str): property name matching the node property. - """ - self._name = name - - def get_value(self): - """ - Returns: - object: widgets current value. - """ - raise NotImplementedError - - def set_value(self, value): - """ - Args: - value (object): property value to update the widget. - """ - raise NotImplementedError diff --git a/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py b/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py deleted file mode 100644 index 01e517ab2..000000000 --- a/cuegui/NodeGraphQt/custom_widgets/properties_bin/prop_widgets_base.py +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/python -from qtpy import QtWidgets, QtCore - - -class PropLabel(QtWidgets.QLabel): - """ - Displays a node property as a "QLabel" widget in the PropertiesBin widget. - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(PropLabel, self).__init__(parent) - self._name = None - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def get_name(self): - return self._name - - def set_name(self, name): - self._name = name - - def get_value(self): - return self.text() - - def set_value(self, value): - if value != self.get_value(): - self.setText(str(value)) - self.value_changed.emit(self.get_name(), value) - - -class PropLineEdit(QtWidgets.QLineEdit): - """ - Displays a node property as a "QLineEdit" widget in the PropertiesBin - widget. - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(PropLineEdit, self).__init__(parent) - self._name = None - self.editingFinished.connect(self._on_editing_finished) - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def _on_editing_finished(self): - self.value_changed.emit(self.get_name(), self.text()) - - def get_name(self): - return self._name - - def set_name(self, name): - self._name = name - - def get_value(self): - return self.text() - - def set_value(self, value): - _value = str(value) - if _value != self.get_value(): - self.setText(_value) - self.value_changed.emit(self.get_name(), _value) - - -class PropTextEdit(QtWidgets.QTextEdit): - """ - Displays a node property as a "QTextEdit" widget in the PropertiesBin - widget. - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(PropTextEdit, self).__init__(parent) - self._name = None - self._prev_text = '' - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def focusInEvent(self, event): - super(PropTextEdit, self).focusInEvent(event) - self._prev_text = self.toPlainText() - - def focusOutEvent(self, event): - super(PropTextEdit, self).focusOutEvent(event) - if self._prev_text != self.toPlainText(): - self.value_changed.emit(self.get_name(), self.toPlainText()) - self._prev_text = '' - - def get_name(self): - return self._name - - def set_name(self, name): - self._name = name - - def get_value(self): - return self.toPlainText() - - def set_value(self, value): - _value = str(value) - if _value != self.get_value(): - self.setPlainText(_value) - self.value_changed.emit(self.get_name(), _value) - - -class PropComboBox(QtWidgets.QComboBox): - """ - Displays a node property as a "QComboBox" widget in the PropertiesBin - widget. - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(PropComboBox, self).__init__(parent) - self._name = None - self.currentIndexChanged.connect(self._on_index_changed) - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def _on_index_changed(self): - self.value_changed.emit(self.get_name(), self.get_value()) - - def items(self): - """ - Returns items from the combobox. - - Returns: - list[str]: list of strings. - """ - return [self.itemText(i) for i in range(self.count())] - - def set_items(self, items): - """ - Set items on the combobox. - - Args: - items (list[str]): list of strings. - """ - self.clear() - self.addItems(items) - - def get_name(self): - return self._name - - def set_name(self, name): - self._name = name - - def get_value(self): - return self.currentText() - - def set_value(self, value): - if value != self.get_value(): - idx = self.findText(value, QtCore.Qt.MatchFlag.MatchExactly) - self.setCurrentIndex(idx) - if idx >= 0: - self.value_changed.emit(self.get_name(), value) - - -class PropCheckBox(QtWidgets.QCheckBox): - """ - Displays a node property as a "QCheckBox" widget in the PropertiesBin - widget. - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(PropCheckBox, self).__init__(parent) - self._name = None - self.clicked.connect(self._on_clicked) - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def _on_clicked(self): - self.value_changed.emit(self.get_name(), self.get_value()) - - def get_name(self): - return self._name - - def set_name(self, name): - self._name = name - - def get_value(self): - return self.isChecked() - - def set_value(self, value): - _value = bool(value) - if _value != self.get_value(): - self.setChecked(_value) - self.value_changed.emit(self.get_name(), _value) - - -class PropSpinBox(QtWidgets.QSpinBox): - """ - Displays a node property as a "QSpinBox" widget in the PropertiesBin widget. - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(PropSpinBox, self).__init__(parent) - self._name = None - self.setButtonSymbols(self.ButtonSymbols.NoButtons) - self.valueChanged.connect(self._on_value_change) - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def _on_value_change(self, value): - self.value_changed.emit(self.get_name(), value) - - def get_name(self): - return self._name - - def set_name(self, name): - self._name = name - - def get_value(self): - return self.value() - - def set_value(self, value): - if value != self.get_value(): - self.setValue(value) - - -class PropDoubleSpinBox(QtWidgets.QDoubleSpinBox): - """ - Displays a node property as a "QDoubleSpinBox" widget in the PropertiesBin - widget. - """ - - value_changed = QtCore.Signal(str, object) - - def __init__(self, parent=None): - super(PropDoubleSpinBox, self).__init__(parent) - self._name = None - self.setButtonSymbols(self.ButtonSymbols.NoButtons) - self.valueChanged.connect(self._on_value_change) - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def _on_value_change(self, value): - self.value_changed.emit(self.get_name(), value) - - def get_name(self): - return self._name - - def set_name(self, name): - self._name = name - - def get_value(self): - return self.value() - - def set_value(self, value): - if value != self.get_value(): - self.setValue(value) - - -# class PropPushButton(QtWidgets.QPushButton): -# """ -# Displays a node property as a "QPushButton" widget in the PropertiesBin -# widget. -# """ -# -# value_changed = QtCore.Signal(str, object) -# button_clicked = QtCore.Signal(str, object) -# -# def __init__(self, parent=None): -# super(PropPushButton, self).__init__(parent) -# self._name = None -# self.clicked.connect(self.button_clicked.emit) -# -# def set_on_click_func(self, func, node): -# """ -# Sets slot function for the PropPushButton widget. -# -# Args: -# func (function): property slot function. -# node (NodeGraphQt.NodeObject): node object. -# """ -# if not callable(func): -# raise TypeError('var func is not a function.') -# self.clicked.connect(lambda: func(node)) -# -# def get_value(self): -# return -# -# def set_value(self, value): -# return diff --git a/cuegui/NodeGraphQt/errors.py b/cuegui/NodeGraphQt/errors.py deleted file mode 100644 index da23b1221..000000000 --- a/cuegui/NodeGraphQt/errors.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - - -class NodeMenuError(Exception): pass - - -class NodePropertyError(Exception): pass - - -class NodeWidgetError(Exception): pass - - -class NodeCreationError(Exception): pass - - -class NodeDeletionError(Exception): pass - - -class NodeRegistrationError(Exception): pass - - -class PortError(Exception): pass - - -class PortRegistrationError(Exception): pass diff --git a/cuegui/NodeGraphQt/nodes/__init__.py b/cuegui/NodeGraphQt/nodes/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cuegui/NodeGraphQt/nodes/backdrop_node.py b/cuegui/NodeGraphQt/nodes/backdrop_node.py deleted file mode 100644 index 1aa9d6db7..000000000 --- a/cuegui/NodeGraphQt/nodes/backdrop_node.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/python -from NodeGraphQt.base.node import NodeObject -from NodeGraphQt.constants import NodePropWidgetEnum -from NodeGraphQt.qgraphics.node_backdrop import BackdropNodeItem - - -class BackdropNode(NodeObject): - """ - The ``NodeGraphQt.BackdropNode`` class allows other node object to be - nested inside, it's mainly good for grouping nodes together. - - .. inheritance-diagram:: NodeGraphQt.BackdropNode - - .. image:: ../_images/backdrop.png - :width: 250px - - - - """ - - NODE_NAME = 'Backdrop' - - def __init__(self, qgraphics_views=None): - super(BackdropNode, self).__init__(qgraphics_views or BackdropNodeItem) - # override base default color. - self.model.color = (5, 129, 138, 255) - self.create_property('backdrop_text', '', - widget_type=NodePropWidgetEnum.QTEXT_EDIT.value, - tab='Backdrop') - - def on_backdrop_updated(self, update_prop, value=None): - """ - Slot triggered by the "on_backdrop_updated" signal from - the node graph. - - Args: - update_prop (str): update property type. - value (object): update value (optional) - """ - if update_prop == 'sizer_mouse_release': - self.graph.begin_undo('resized "{}"'.format(self.name())) - self.set_property('width', value['width']) - self.set_property('height', value['height']) - self.set_pos(*value['pos']) - self.graph.end_undo() - elif update_prop == 'sizer_double_clicked': - self.graph.begin_undo('"{}" auto resize'.format(self.name())) - self.set_property('width', value['width']) - self.set_property('height', value['height']) - self.set_pos(*value['pos']) - self.graph.end_undo() - - def auto_size(self): - """ - Auto resize the backdrop node to fit around the intersecting nodes. - """ - self.graph.begin_undo('"{}" auto resize'.format(self.name())) - size = self.view.calc_backdrop_size() - self.set_property('width', size['width']) - self.set_property('height', size['height']) - self.set_pos(*size['pos']) - self.graph.end_undo() - - def wrap_nodes(self, nodes): - """ - Set the backdrop size to fit around specified nodes. - - Args: - nodes (list[NodeGraphQt.NodeObject]): list of nodes. - """ - if not nodes: - return - self.graph.begin_undo('"{}" wrap nodes'.format(self.name())) - size = self.view.calc_backdrop_size([n.view for n in nodes]) - self.set_property('width', size['width']) - self.set_property('height', size['height']) - self.set_pos(*size['pos']) - self.graph.end_undo() - - def nodes(self): - """ - Returns nodes wrapped within the backdrop node. - - Returns: - list[NodeGraphQt.BaseNode]: list of node under the backdrop. - """ - node_ids = [n.id for n in self.view.get_nodes()] - return [self.graph.get_node_by_id(nid) for nid in node_ids] - - def set_text(self, text=''): - """ - Sets the text to be displayed in the backdrop node. - - Args: - text (str): text string. - """ - self.set_property('backdrop_text', text) - - def text(self): - """ - Returns the text on the backdrop node. - - Returns: - str: text string. - """ - return self.get_property('backdrop_text') - - def set_size(self, width, height): - """ - Sets the backdrop size. - - Args: - width (float): backdrop width size. - height (float): backdrop height size. - """ - if self.graph: - self.graph.begin_undo('backdrop size') - self.set_property('width', width) - self.set_property('height', height) - self.graph.end_undo() - return - self.view.width, self.view.height = width, height - self.model.width, self.model.height = width, height - - def size(self): - """ - Returns the current size of the node. - - Returns: - tuple: node width, height - """ - self.model.width = self.view.width - self.model.height = self.view.height - return self.model.width, self.model.height - - def inputs(self): - # required function but unused for the backdrop node. - return - - def outputs(self): - # required function but unused for the backdrop node. - return diff --git a/cuegui/NodeGraphQt/nodes/base_node.py b/cuegui/NodeGraphQt/nodes/base_node.py deleted file mode 100644 index 2c505b410..000000000 --- a/cuegui/NodeGraphQt/nodes/base_node.py +++ /dev/null @@ -1,872 +0,0 @@ -#!/usr/bin/python -from collections import OrderedDict - -from NodeGraphQt.base.commands import NodeVisibleCmd, NodeWidgetVisibleCmd -from NodeGraphQt.base.node import NodeObject -from NodeGraphQt.base.port import Port -from NodeGraphQt.constants import NodePropWidgetEnum, PortTypeEnum -from NodeGraphQt.errors import ( - PortError, - PortRegistrationError, - NodeWidgetError -) -from NodeGraphQt.qgraphics.node_base import NodeItem -from NodeGraphQt.widgets.node_widgets import ( - NodeBaseWidget, - NodeCheckBox, - NodeComboBox, - NodeLineEdit -) - - -class BaseNode(NodeObject): - """ - The ``NodeGraphQt.BaseNode`` class is the base class for nodes that allows - port connections from one node to another. - - .. inheritance-diagram:: NodeGraphQt.BaseNode - - .. image:: ../_images/node.png - :width: 250px - - example snippet: - - .. code-block:: python - :linenos: - - from NodeGraphQt import BaseNode - - class ExampleNode(BaseNode): - - # unique node identifier domain. - __identifier__ = 'io.jchanvfx.github' - - # initial default node name. - NODE_NAME = 'My Node' - - def __init__(self): - super(ExampleNode, self).__init__() - - # create an input port. - self.add_input('in') - - # create an output port. - self.add_output('out') - """ - - NODE_NAME = 'Node' - - def __init__(self, qgraphics_item=None): - super(BaseNode, self).__init__(qgraphics_item or NodeItem) - self._inputs = [] - self._outputs = [] - - def update_model(self): - """ - Update the node model from view. - """ - for name, val in self.view.properties.items(): - if name in ['inputs', 'outputs']: - continue - self.model.set_property(name, val) - - for name, widget in self.view.widgets.items(): - self.model.set_property(name, widget.get_value()) - - def set_property(self, name, value, push_undo=True): - """ - Set the value on the node custom property. - - Args: - name (str): name of the property. - value (object): property data (python built in types). - push_undo (bool): register the command to the undo stack. (default: True) - """ - # prevent signals from causing a infinite loop. - if self.get_property(name) == value: - return - - if name == 'visible': - if self.graph: - undo_cmd = NodeVisibleCmd(self, value) - if push_undo: - self.graph.undo_stack().push(undo_cmd) - else: - undo_cmd.redo() - return - elif name == 'disabled': - # redraw the connected pipes in the scene. - ports = self.view.inputs + self.view.outputs - for port in ports: - for pipe in port.connected_pipes: - pipe.update() - super(BaseNode, self).set_property(name, value, push_undo) - - def set_layout_direction(self, value=0): - """ - Sets the node layout direction to either horizontal or vertical on - the current node only. - - `Implemented in` ``v0.3.0`` - - See Also: - :meth:`NodeGraph.set_layout_direction`, - :meth:`NodeObject.layout_direction` - - - Warnings: - This function does not register to the undo stack. - - Args: - value (int): layout direction mode. - """ - # base logic to update the model and view attributes only. - super(BaseNode, self).set_layout_direction(value) - # redraw the node. - self._view.draw_node() - - def set_icon(self, icon=None): - """ - Set the node icon. - - Args: - icon (str): path to the icon image. - """ - self.set_property('icon', icon) - - def icon(self): - """ - Node icon path. - - Returns: - str: icon image file path. - """ - return self.model.icon - - def widgets(self): - """ - Returns all embedded widgets from this node. - - See Also: - :meth:`BaseNode.get_widget` - - Returns: - dict: embedded node widgets. {``property_name``: ``node_widget``} - """ - return self.view.widgets - - def get_widget(self, name): - """ - Returns the embedded widget associated with the property name. - - See Also: - :meth:`BaseNode.add_combo_menu`, - :meth:`BaseNode.add_text_input`, - :meth:`BaseNode.add_checkbox`, - - Args: - name (str): node property name. - - Returns: - NodeBaseWidget: embedded node widget. - """ - return self.view.widgets.get(name) - - def add_custom_widget(self, widget, widget_type=None, tab=None): - """ - Add a custom node widget into the node. - - see example :ref:`Embedding Custom Widgets`. - - Note: - The ``value_changed`` signal from the added node widget is wired - up to the :meth:`NodeObject.set_property` function. - - Args: - widget (NodeBaseWidget): node widget class object. - widget_type: widget flag to display in the - :class:`NodeGraphQt.PropertiesBinWidget` - (default: :attr:`NodeGraphQt.constants.NodePropWidgetEnum.HIDDEN`). - tab (str): name of the widget tab to display in. - """ - if not isinstance(widget, NodeBaseWidget): - raise NodeWidgetError( - '\'widget\' must be an instance of a NodeBaseWidget') - - widget_type = widget_type or NodePropWidgetEnum.HIDDEN.value - self.create_property(widget.get_name(), - widget.get_value(), - widget_type=widget_type, - tab=tab) - widget.value_changed.connect(lambda k, v: self.set_property(k, v)) - widget._node = self - self.view.add_widget(widget) - #: redraw node to address calls outside the "__init__" func. - self.view.draw_node() - - def add_combo_menu(self, name, label='', items=None, tooltip=None, - tab=None): - """ - Creates a custom property with the :meth:`NodeObject.create_property` - function and embeds a :class:`PySide2.QtWidgets.QComboBox` widget - into the node. - - Note: - The ``value_changed`` signal from the added node widget is wired - up to the :meth:`NodeObject.set_property` function. - - Args: - name (str): name for the custom property. - label (str): label to be displayed. - items (list[str]): items to be added into the menu. - tooltip (str): widget tooltip. - tab (str): name of the widget tab to display in. - """ - self.create_property( - name, - value=items[0] if items else None, - items=items or [], - widget_type=NodePropWidgetEnum.QCOMBO_BOX.value, - widget_tooltip=tooltip, - tab=tab - ) - widget = NodeComboBox(self.view, name, label, items) - widget.setToolTip(tooltip or '') - widget.value_changed.connect(lambda k, v: self.set_property(k, v)) - self.view.add_widget(widget) - #: redraw node to address calls outside the "__init__" func. - self.view.draw_node() - - def add_text_input(self, name, label='', text='', placeholder_text='', - tooltip=None, tab=None): - """ - Creates a custom property with the :meth:`NodeObject.create_property` - function and embeds a :class:`PySide2.QtWidgets.QLineEdit` widget - into the node. - - Note: - The ``value_changed`` signal from the added node widget is wired - up to the :meth:`NodeObject.set_property` function. - - Args: - name (str): name for the custom property. - label (str): label to be displayed. - text (str): pre-filled text. - placeholder_text (str): placeholder text. - tooltip (str): widget tooltip. - tab (str): name of the widget tab to display in. - """ - self.create_property( - name, - value=text, - widget_type=NodePropWidgetEnum.QLINE_EDIT.value, - widget_tooltip=tooltip, - tab=tab - ) - widget = NodeLineEdit(self.view, name, label, text, placeholder_text) - widget.setToolTip(tooltip or '') - widget.value_changed.connect(lambda k, v: self.set_property(k, v)) - self.view.add_widget(widget) - #: redraw node to address calls outside the "__init__" func. - self.view.draw_node() - - def add_checkbox(self, name, label='', text='', state=False, tooltip=None, - tab=None): - """ - Creates a custom property with the :meth:`NodeObject.create_property` - function and embeds a :class:`PySide2.QtWidgets.QCheckBox` widget - into the node. - - Note: - The ``value_changed`` signal from the added node widget is wired - up to the :meth:`NodeObject.set_property` function. - - Args: - name (str): name for the custom property. - label (str): label to be displayed. - text (str): checkbox text. - state (bool): pre-check. - tooltip (str): widget tooltip. - tab (str): name of the widget tab to display in. - """ - self.create_property( - name, - value=state, - widget_type=NodePropWidgetEnum.QCHECK_BOX.value, - widget_tooltip=tooltip, - tab=tab - ) - widget = NodeCheckBox(self.view, name, label, text, state) - widget.setToolTip(tooltip or '') - widget.value_changed.connect(lambda k, v: self.set_property(k, v)) - self.view.add_widget(widget) - #: redraw node to address calls outside the "__init__" func. - self.view.draw_node() - - def hide_widget(self, name, push_undo=True): - """ - Hide an embedded node widget. - - Args: - name (str): node property name for the widget. - push_undo (bool): register the command to the undo stack. (default: True) - - See Also: - :meth:`BaseNode.add_custom_widget`, - :meth:`BaseNode.show_widget`, - :meth:`BaseNode.get_widget` - """ - if not self.view.has_widget(name): - return - undo_cmd = NodeWidgetVisibleCmd(self, name, visible=False) - if push_undo: - self.graph.undo_stack().push(undo_cmd) - else: - undo_cmd.redo() - - def show_widget(self, name, push_undo=True): - """ - Show an embedded node widget. - - Args: - name (str): node property name for the widget. - push_undo (bool): register the command to the undo stack. (default: True) - - See Also: - :meth:`BaseNode.add_custom_widget`, - :meth:`BaseNode.hide_widget`, - :meth:`BaseNode.get_widget` - """ - if not self.view.has_widget(name): - return - undo_cmd = NodeWidgetVisibleCmd(self, name, visible=True) - if push_undo: - self.graph.undo_stack().push(undo_cmd) - else: - undo_cmd.redo() - - def add_input(self, name='input', multi_input=False, display_name=True, - color=None, locked=False, painter_func=None): - """ - Add input :class:`Port` to node. - - Warnings: - Undo is NOT supported for this function. - - Args: - name (str): name for the input port. - multi_input (bool): allow port to have more than one connection. - display_name (bool): display the port name on the node. - color (tuple): initial port color (r, g, b) ``0-255``. - locked (bool): locked state see :meth:`Port.set_locked` - painter_func (function or None): custom function to override the drawing - of the port shape see example: :ref:`Creating Custom Shapes` - - Returns: - NodeGraphQt.Port: the created port object. - """ - if name in self.inputs().keys(): - raise PortRegistrationError( - 'port name "{}" already registered.'.format(name)) - - port_args = [name, multi_input, display_name, locked] - if painter_func and callable(painter_func): - port_args.append(painter_func) - view = self.view.add_input(*port_args) - - if color: - view.color = color - view.border_color = [min([255, max([0, i + 80])]) for i in color] - - port = Port(self, view) - port.model.type_ = PortTypeEnum.IN.value - port.model.name = name - port.model.display_name = display_name - port.model.multi_connection = multi_input - port.model.locked = locked - self._inputs.append(port) - self.model.inputs[port.name()] = port.model - return port - - def add_output(self, name='output', multi_output=True, display_name=True, - color=None, locked=False, painter_func=None): - """ - Add output :class:`Port` to node. - - Warnings: - Undo is NOT supported for this function. - - Args: - name (str): name for the output port. - multi_output (bool): allow port to have more than one connection. - display_name (bool): display the port name on the node. - color (tuple): initial port color (r, g, b) ``0-255``. - locked (bool): locked state see :meth:`Port.set_locked` - painter_func (function or None): custom function to override the drawing - of the port shape see example: :ref:`Creating Custom Shapes` - - Returns: - NodeGraphQt.Port: the created port object. - """ - if name in self.outputs().keys(): - raise PortRegistrationError( - 'port name "{}" already registered.'.format(name)) - - port_args = [name, multi_output, display_name, locked] - if painter_func and callable(painter_func): - port_args.append(painter_func) - view = self.view.add_output(*port_args) - - if color: - view.color = color - view.border_color = [min([255, max([0, i + 80])]) for i in color] - port = Port(self, view) - port.model.type_ = PortTypeEnum.OUT.value - port.model.name = name - port.model.display_name = display_name - port.model.multi_connection = multi_output - port.model.locked = locked - self._outputs.append(port) - self.model.outputs[port.name()] = port.model - return port - - def get_input(self, port): - """ - Get input port by the name or index. - - Args: - port (str or int): port name or index. - - Returns: - NodeGraphQt.Port: node port. - """ - if type(port) is int: - if port < len(self._inputs): - return self._inputs[port] - elif type(port) is str: - return self.inputs().get(port, None) - - def get_output(self, port): - """ - Get output port by the name or index. - - Args: - port (str or int): port name or index. - - Returns: - NodeGraphQt.Port: node port. - """ - if type(port) is int: - if port < len(self._outputs): - return self._outputs[port] - elif type(port) is str: - return self.outputs().get(port, None) - - def delete_input(self, port): - """ - Delete input port. - - Warnings: - Undo is NOT supported for this function. - - You can only delete ports if :meth:`BaseNode.port_deletion_allowed` - returns ``True`` otherwise a port error is raised see also - :meth:`BaseNode.set_port_deletion_allowed`. - - Args: - port (str or int): port name or index. - """ - if type(port) in [int, str]: - port = self.get_input(port) - if port is None: - return - if not self.port_deletion_allowed(): - raise PortError( - 'Port "{}" can\'t be deleted on this node because ' - '"ports_removable" is not enabled.'.format(port.name())) - if port.locked(): - raise PortError('Error: Can\'t delete a port that is locked!') - self._inputs.remove(port) - self._model.inputs.pop(port.name()) - self._view.delete_input(port.view) - port.model.node = None - self._view.draw_node() - - def delete_output(self, port): - """ - Delete output port. - - Warnings: - Undo is NOT supported for this function. - - You can only delete ports if :meth:`BaseNode.port_deletion_allowed` - returns ``True`` otherwise a port error is raised see also - :meth:`BaseNode.set_port_deletion_allowed`. - - Args: - port (str or int): port name or index. - """ - if type(port) in [int, str]: - port = self.get_output(port) - if port is None: - return - if not self.port_deletion_allowed(): - raise PortError( - 'Port "{}" can\'t be deleted on this node because ' - '"ports_removable" is not enabled.'.format(port.name())) - if port.locked(): - raise PortError('Error: Can\'t delete a port that is locked!') - self._outputs.remove(port) - self._model.outputs.pop(port.name()) - self._view.delete_output(port.view) - port.model.node = None - self._view.draw_node() - - def set_port_deletion_allowed(self, mode=False): - """ - Allow ports to be removable on this node. - - See Also: - :meth:`BaseNode.port_deletion_allowed` and - :meth:`BaseNode.set_ports` - - Args: - mode (bool): true to allow. - """ - self.model.port_deletion_allowed = mode - - def port_deletion_allowed(self): - """ - Return true if ports can be deleted on this node. - - See Also: - :meth:`BaseNode.set_port_deletion_allowed` - - Returns: - bool: true if ports can be deleted. - """ - return self.model.port_deletion_allowed - - def set_ports(self, port_data): - """ - Create node input and output ports from serialized port data. - - Warnings: - You can only use this function if the node has - :meth:`BaseNode.port_deletion_allowed` is `True` - see :meth:`BaseNode.set_port_deletion_allowed` - - Hint: - example snippet of port data. - - .. highlight:: python - .. code-block:: python - - { - 'input_ports': - [{ - 'name': 'input', - 'multi_connection': True, - 'display_name': 'Input', - 'locked': False - }], - 'output_ports': - [{ - 'name': 'output', - 'multi_connection': True, - 'display_name': 'Output', - 'locked': False - }] - } - - Args: - port_data(dict): port data. - """ - if not self.port_deletion_allowed(): - raise PortError( - 'Ports cannot be set on this node because ' - '"set_port_deletion_allowed" is not enabled on this node.') - - for port in self._inputs: - self._view.delete_input(port.view) - port.model.node = None - for port in self._outputs: - self._view.delete_output(port.view) - port.model.node = None - self._inputs = [] - self._outputs = [] - self._model.outputs = {} - self._model.inputs = {} - - [self.add_input(name=port['name'], - multi_input=port['multi_connection'], - display_name=port['display_name'], - locked=port.get('locked') or False) - for port in port_data['input_ports']] - [self.add_output(name=port['name'], - multi_output=port['multi_connection'], - display_name=port['display_name'], - locked=port.get('locked') or False) - for port in port_data['output_ports']] - self._view.draw_node() - - def inputs(self): - """ - Returns all the input ports from the node. - - Returns: - dict: {: } - """ - return {p.name(): p for p in self._inputs} - - def input_ports(self): - """ - Return all input ports. - - Returns: - list[NodeGraphQt.Port]: node input ports. - """ - return self._inputs - - def outputs(self): - """ - Returns all the output ports from the node. - - Returns: - dict: {: } - """ - return {p.name(): p for p in self._outputs} - - def output_ports(self): - """ - Return all output ports. - - Returns: - list[NodeGraphQt.Port]: node output ports. - """ - return self._outputs - - def input(self, index): - """ - Return the input port with the matching index. - - Args: - index (int): index of the input port. - - Returns: - NodeGraphQt.Port: port object. - """ - return self._inputs[index] - - def set_input(self, index, port): - """ - Creates a connection pipe to the targeted output :class:`Port`. - - Args: - index (int): index of the port. - port (NodeGraphQt.Port): port object. - """ - src_port = self.input(index) - src_port.connect_to(port) - - def output(self, index): - """ - Return the output port with the matching index. - - Args: - index (int): index of the output port. - - Returns: - NodeGraphQt.Port: port object. - """ - return self._outputs[index] - - def set_output(self, index, port): - """ - Creates a connection pipe to the targeted input :class:`Port`. - - Args: - index (int): index of the port. - port (NodeGraphQt.Port): port object. - """ - src_port = self.output(index) - src_port.connect_to(port) - - def connected_input_nodes(self): - """ - Returns all nodes connected from the input ports. - - Returns: - dict: {: } - """ - nodes = OrderedDict() - for p in self.input_ports(): - nodes[p] = [cp.node() for cp in p.connected_ports()] - return nodes - - def connected_output_nodes(self): - """ - Returns all nodes connected from the output ports. - - Returns: - dict: {: } - """ - nodes = OrderedDict() - for p in self.output_ports(): - nodes[p] = [cp.node() for cp in p.connected_ports()] - return nodes - - def add_accept_port_type(self, port, port_type_data): - """ - Add an accept constrain to a specified node port. - - Once a constraint has been added only ports of that type specified will - be allowed a pipe connection. - - port type data example - - .. highlight:: python - .. code-block:: python - - { - 'port_name': 'foo' - 'port_type': PortTypeEnum.IN.value - 'node_type': 'io.github.jchanvfx.NodeClass' - } - - See Also: - :meth:`NodeGraphQt.BaseNode.accepted_port_types` - - Args: - port (NodeGraphQt.Port): port to assign constrain to. - port_type_data (dict): port type data to accept a connection - """ - node_ports = self._inputs + self._outputs - if port not in node_ports: - raise PortError('Node does not contain port: "{}"'.format(port)) - - self._model.add_port_accept_connection_type( - port_name=port.name(), - port_type=port.type_(), - node_type=self.type_, - accept_pname=port_type_data['port_name'], - accept_ptype=port_type_data['port_type'], - accept_ntype=port_type_data['node_type'] - ) - - def accepted_port_types(self, port): - """ - Returns a dictionary of connection constrains of the port types - that allow for a pipe connection to this node. - - Args: - port (NodeGraphQt.Port): port object. - - Returns: - dict: {: {: []}} - """ - ports = self._inputs + self._outputs - if port not in ports: - raise PortError('Node does not contain port "{}"'.format(port)) - - accepted_types = self.graph.model.port_accept_connection_types( - node_type=self.type_, - port_type=port.type_(), - port_name=port.name() - ) - return accepted_types - - def add_reject_port_type(self, port, port_type_data): - """ - Add a reject constrain to a specified node port. - - Once a constraint has been added only ports of that type specified will - NOT be allowed a pipe connection. - - port type data example - - .. highlight:: python - .. code-block:: python - - { - 'port_name': 'foo' - 'port_type': PortTypeEnum.IN.value - 'node_type': 'io.github.jchanvfx.NodeClass' - } - - See Also: - :meth:`NodeGraphQt.Port.rejected_port_types` - - Args: - port (NodeGraphQt.Port): port to assign constrain to. - port_type_data (dict): port type data to reject a connection - """ - node_ports = self._inputs + self._outputs - if port not in node_ports: - raise PortError('Node does not contain port: "{}"'.format(port)) - - self._model.add_port_reject_connection_type( - port_name=port.name(), - port_type=port.type_(), - node_type=self.type_, - reject_pname=port_type_data['port_name'], - reject_ptype=port_type_data['port_type'], - reject_ntype=port_type_data['node_type'] - ) - - def rejected_port_types(self, port): - """ - Returns a dictionary of connection constrains of the port types - that are NOT allowed for a pipe connection to this node. - - Args: - port (NodeGraphQt.Port): port object. - - Returns: - dict: {: {: []}} - """ - ports = self._inputs + self._outputs - if port not in ports: - raise PortError('Node does not contain port "{}"'.format(port)) - - rejected_types = self.graph.model.port_reject_connection_types( - node_type=self.type_, - port_type=port.type_(), - port_name=port.name() - ) - return rejected_types - - def on_input_connected(self, in_port, out_port): - """ - Callback triggered when a new pipe connection is made. - - *The default of this function does nothing re-implement if you require - logic to run for this event.* - - Note: - to work with undo & redo for this method re-implement - :meth:`BaseNode.on_input_disconnected` with the reverse logic. - - Args: - in_port (NodeGraphQt.Port): source input port from this node. - out_port (NodeGraphQt.Port): output port that connected to this node. - """ - return - - def on_input_disconnected(self, in_port, out_port): - """ - Callback triggered when a pipe connection has been disconnected - from a INPUT port. - - *The default of this function does nothing re-implement if you require - logic to run for this event.* - - Note: - to work with undo & redo for this method re-implement - :meth:`BaseNode.on_input_connected` with the reverse logic. - - Args: - in_port (NodeGraphQt.Port): source input port from this node. - out_port (NodeGraphQt.Port): output port that was disconnected. - """ - return diff --git a/cuegui/NodeGraphQt/nodes/base_node_circle.py b/cuegui/NodeGraphQt/nodes/base_node_circle.py deleted file mode 100644 index 6aeb6e0d4..000000000 --- a/cuegui/NodeGraphQt/nodes/base_node_circle.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/python -from NodeGraphQt.nodes.base_node import BaseNode -from NodeGraphQt.qgraphics.node_circle import CircleNodeItem - - -class BaseNodeCircle(BaseNode): - """ - `Implemented in` ``v0.5.2`` - - The ``NodeGraphQt.BaseNodeCircle`` is pretty much the same class as the - :class:`NodeGraphQt.BaseNode` except with a different design. - - .. inheritance-diagram:: NodeGraphQt.BaseNodeCircle - - .. image:: ../_images/node_circle.png - :width: 250px - - example snippet: - - .. code-block:: python - :linenos: - - from NodeGraphQt import BaseNodeCircle - - class ExampleNode(BaseNodeCircle): - - # unique node identifier domain. - __identifier__ = 'io.jchanvfx.github' - - # initial default node name. - NODE_NAME = 'My Node' - - def __init__(self): - super(ExampleNode, self).__init__() - - # create an input port. - self.add_input('in') - - # create an output port. - self.add_output('out') - """ - - NODE_NAME = 'Circle Node' - - def __init__(self, qgraphics_item=None): - super(BaseNodeCircle, self).__init__(qgraphics_item or CircleNodeItem) diff --git a/cuegui/NodeGraphQt/nodes/group_node.py b/cuegui/NodeGraphQt/nodes/group_node.py deleted file mode 100644 index 8e14e4be8..000000000 --- a/cuegui/NodeGraphQt/nodes/group_node.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/python -from NodeGraphQt.nodes.base_node import BaseNode -from NodeGraphQt.nodes.port_node import PortInputNode, PortOutputNode -from NodeGraphQt.qgraphics.node_group import GroupNodeItem - - -class GroupNode(BaseNode): - """ - `Implemented in` ``v0.2.0`` - - The ``NodeGraphQt.GroupNode`` class extends from the :class:`NodeGraphQt.BaseNode` - class with the ability to nest other nodes inside of it. - - .. inheritance-diagram:: NodeGraphQt.GroupNode - - .. image:: ../_images/group_node.png - :width: 250px - - - - """ - - NODE_NAME = 'Group' - - def __init__(self, qgraphics_item=None): - super(GroupNode, self).__init__(qgraphics_item or GroupNodeItem) - self._input_port_nodes = {} - self._output_port_nodes = {} - - @property - def is_expanded(self): - """ - Returns if the group node is expanded or collapsed. - - Returns: - bool: true if the node is expanded. - """ - if not self.graph: - return False - return bool(self.id in self.graph.sub_graphs) - - def get_sub_graph(self): - """ - Returns the sub graph controller to the group node if initialized - or returns None. - - Returns: - SubGraph: sub graph controller. - """ - return self.graph.sub_graphs.get(self.id) - - def get_sub_graph_session(self): - """ - Returns the serialized sub graph session. - - Returns: - dict: serialized sub graph session. - """ - return self.model.subgraph_session - - def set_sub_graph_session(self, serialized_session): - """ - Sets the sub graph session data to the group node. - - Args: - serialized_session (dict): serialized session. - """ - serialized_session = serialized_session or {} - self.model.subgraph_session = serialized_session - - def expand(self): - """ - Expand the group node session. - - See Also: - :meth:`NodeGraph.expand_group_node`, - :meth:`SubGraph.expand_group_node`. - - Returns: - SubGraph: node graph used to manage the nodes expaneded session. - """ - sub_graph = self.graph.expand_group_node(self) - return sub_graph - - def collapse(self): - """ - Collapse the group node session it's expanded child sub graphs. - - See Also: - :meth:`NodeGraph.collapse_group_node`, - :meth:`SubGraph.collapse_group_node`. - """ - self.graph.collapse_group_node(self) - - def set_name(self, name=''): - super(GroupNode, self).set_name(name) - # update the tab bar and navigation labels. - sub_graph = self.get_sub_graph() - if sub_graph: - nav_widget = sub_graph.navigation_widget - nav_widget.update_label_item(self.name(), self.id) - - if sub_graph.parent_graph.is_root: - root_graph = sub_graph.parent_graph - tab_bar = root_graph.widget.tabBar() - for idx in range(tab_bar.count()): - if tab_bar.tabToolTip(idx) == self.id: - tab_bar.setTabText(idx, self.name()) - break - - def add_input(self, name='input', multi_input=False, display_name=True, - color=None, locked=False, painter_func=None): - port = super(GroupNode, self).add_input( - name=name, - multi_input=multi_input, - display_name=display_name, - color=color, - locked=locked, - painter_func=painter_func - ) - if self.is_expanded: - input_node = PortInputNode(parent_port=port) - input_node.NODE_NAME = port.name() - input_node.model.set_property('name', port.name()) - input_node.add_output(port.name()) - sub_graph = self.get_sub_graph() - sub_graph.add_node(input_node, selected=False, push_undo=False) - - return port - - def add_output(self, name='output', multi_output=True, display_name=True, - color=None, locked=False, painter_func=None): - port = super(GroupNode, self).add_output( - name=name, - multi_output=multi_output, - display_name=display_name, - color=color, - locked=locked, - painter_func=painter_func - ) - if self.is_expanded: - output_port = PortOutputNode(parent_port=port) - output_port.NODE_NAME = port.name() - output_port.model.set_property('name', port.name()) - output_port.add_input(port.name()) - sub_graph = self.get_sub_graph() - sub_graph.add_node(output_port, selected=False, push_undo=False) - - return port - - def delete_input(self, port): - if type(port) in [int, str]: - port = self.get_input(port) - if port is None: - return - - if self.is_expanded: - sub_graph = self.get_sub_graph() - port_node = sub_graph.get_node_by_port(port) - if port_node: - sub_graph.remove_node(port_node, push_undo=False) - - super(GroupNode, self).delete_input(port) - - def delete_output(self, port): - if type(port) in [int, str]: - port = self.get_output(port) - if port is None: - return - - if self.is_expanded: - sub_graph = self.get_sub_graph() - port_node = sub_graph.get_node_by_port(port) - if port_node: - sub_graph.remove_node(port_node, push_undo=False) - - super(GroupNode, self).delete_output(port) diff --git a/cuegui/NodeGraphQt/nodes/port_node.py b/cuegui/NodeGraphQt/nodes/port_node.py deleted file mode 100644 index 7fc4e9196..000000000 --- a/cuegui/NodeGraphQt/nodes/port_node.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/python -from NodeGraphQt.errors import PortRegistrationError -from NodeGraphQt.nodes.base_node import BaseNode -from NodeGraphQt.qgraphics.node_port_in import PortInputNodeItem -from NodeGraphQt.qgraphics.node_port_out import PortOutputNodeItem - - -class PortInputNode(BaseNode): - """ - The ``PortInputNode`` is the node that represents a input port from a - :class:`NodeGraphQt.GroupNode` when expanded in a - :class:`NodeGraphQt.SubGraph`. - - .. inheritance-diagram:: NodeGraphQt.nodes.port_node.PortInputNode - :parts: 1 - - .. image:: ../_images/port_in_node.png - :width: 150px - - - - """ - - NODE_NAME = 'InputPort' - - def __init__(self, qgraphics_item=None, parent_port=None): - super(PortInputNode, self).__init__(qgraphics_item or PortInputNodeItem) - self._parent_port = parent_port - - @property - def parent_port(self): - """ - The parent group node port representing this node. - - Returns: - NodeGraphQt.Port: port object. - """ - return self._parent_port - - def add_input(self, name='input', multi_input=False, display_name=True, - color=None, locked=False, painter_func=None): - """ - Warnings: - This is not available for the ``PortInputNode`` class. - """ - raise PortRegistrationError( - '"{}.add_input()" is not available for {}.' - .format(self.__class__.__name__, self) - ) - - def add_output(self, name='output', multi_output=True, display_name=True, - color=None, locked=False, painter_func=None): - """ - Warnings: - This function is called by :meth:`NodeGraphQt.SubGraph.expand_group_node` - and is not available for the ``PortInputNode`` class. - """ - if self._outputs: - raise PortRegistrationError( - '"{}.add_output()" only ONE output is allowed for this node.' - .format(self.__class__.__name__, self) - ) - super(PortInputNode, self).add_output( - name=name, - multi_output=multi_output, - display_name=False, - color=color, - locked=locked, - painter_func=None - ) - - -class PortOutputNode(BaseNode): - """ - The ``PortOutputNode`` is the node that represents a output port from a - :class:`NodeGraphQt.GroupNode` when expanded in a - :class:`NodeGraphQt.SubGraph`. - - .. inheritance-diagram:: NodeGraphQt.nodes.port_node.PortOutputNode - :parts: 1 - - .. image:: ../_images/port_out_node.png - :width: 150px - - - - """ - - NODE_NAME = 'OutputPort' - - def __init__(self, qgraphics_item=None, parent_port=None): - super(PortOutputNode, self).__init__( - qgraphics_item or PortOutputNodeItem - ) - self._parent_port = parent_port - - @property - def parent_port(self): - """ - The parent group node port representing this node. - - Returns: - NodeGraphQt.Port: port object. - """ - return self._parent_port - - def add_input(self, name='input', multi_input=False, display_name=True, - color=None, locked=False, painter_func=None): - """ - Warnings: - This function is called by :meth:`NodeGraphQt.SubGraph.expand_group_node` - and is not available for the ``PortOutputNode`` class. - """ - if self._inputs: - raise PortRegistrationError( - '"{}.add_input()" only ONE input is allowed for this node.' - .format(self.__class__.__name__, self) - ) - super(PortOutputNode, self).add_input( - name=name, - multi_input=multi_input, - display_name=False, - color=color, - locked=locked, - painter_func=None - ) - - def add_output(self, name='output', multi_output=True, display_name=True, - color=None, locked=False, painter_func=None): - """ - Warnings: - This is not available for the ``PortOutputNode`` class. - """ - raise PortRegistrationError( - '"{}.add_output()" is not available for {}.' - .format(self.__class__.__name__, self) - ) diff --git a/cuegui/NodeGraphQt/pkg_info.py b/cuegui/NodeGraphQt/pkg_info.py deleted file mode 100644 index cfb011d62..000000000 --- a/cuegui/NodeGraphQt/pkg_info.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -__version__ = '0.6.36' -__status__ = 'Work in Progress' -__license__ = 'MIT' - -__author__ = 'Johnny Chan' - -__module_name__ = 'NodeGraphQt' -__url__ = 'https://github.com/jchanvfx/NodeGraphQt' diff --git a/cuegui/NodeGraphQt/qgraphics/__init__.py b/cuegui/NodeGraphQt/qgraphics/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cuegui/NodeGraphQt/qgraphics/node_abstract.py b/cuegui/NodeGraphQt/qgraphics/node_abstract.py deleted file mode 100644 index 4529fe458..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_abstract.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/python -from qtpy import QtCore, QtWidgets - -from NodeGraphQt.constants import ( - Z_VAL_NODE, - ITEM_CACHE_MODE, - LayoutDirectionEnum, - NodeEnum -) - - -class AbstractNodeItem(QtWidgets.QGraphicsItem): - """ - The base class of all node qgraphics item. - """ - - def __init__(self, name='node', parent=None): - super(AbstractNodeItem, self).__init__(parent) - self.setFlags(self.GraphicsItemFlag.ItemIsSelectable | self.GraphicsItemFlag.ItemIsMovable) - self.setCacheMode(ITEM_CACHE_MODE) - self.setZValue(Z_VAL_NODE) - self._properties = { - 'id': None, - 'name': name.strip(), - 'color': (13, 18, 23, 255), - 'border_color': (46, 57, 66, 255), - 'text_color': (255, 255, 255, 180), - 'type_': 'AbstractBaseNode', - 'selected': False, - 'disabled': False, - 'visible': False, - 'layout_direction': LayoutDirectionEnum.HORIZONTAL.value, - } - self._width = NodeEnum.WIDTH.value - self._height = NodeEnum.HEIGHT.value - - def __repr__(self): - return '{}.{}(\'{}\')'.format( - self.__module__, self.__class__.__name__, self.name) - - def boundingRect(self): - return QtCore.QRectF(0.0, 0.0, self._width, self._height) - - def mousePressEvent(self, event): - """ - Re-implemented to update "self._properties['selected']" attribute. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. - """ - self._properties['selected'] = True - super(AbstractNodeItem, self).mousePressEvent(event) - - def setSelected(self, selected): - self._properties['selected'] = selected - super(AbstractNodeItem, self).setSelected(selected) - - def draw_node(self): - """ - Re-draw the node item in the scene with proper - calculated size and widgets aligned. - - (this is called from the builtin custom widgets.) - """ - return - - def pre_init(self, viewer, pos=None): - """ - Called before node has been added into the scene. - - Args: - viewer (NodeGraphQt.widgets.viewer.NodeViewer): main viewer. - pos (tuple): the cursor pos if node is called with tab search. - """ - return - - def post_init(self, viewer, pos=None): - """ - Called after node has been added into the scene. - - Args: - viewer (NodeGraphQt.widgets.viewer.NodeViewer): main viewer - pos (tuple): the cursor pos if node is called with tab search. - """ - return - - @property - def id(self): - return self._properties['id'] - - @id.setter - def id(self, unique_id=''): - self._properties['id'] = unique_id - - @property - def type_(self): - return self._properties['type_'] - - @type_.setter - def type_(self, node_type='NODE'): - self._properties['type_'] = node_type - - @property - def layout_direction(self): - return self._properties['layout_direction'] - - @layout_direction.setter - def layout_direction(self, value=0): - self._properties['layout_direction'] = value - - @property - def size(self): - return self._width, self._height - - @property - def width(self): - return self._width - - @width.setter - def width(self, width=0.0): - self._width = width - - @property - def height(self): - return self._height - - @height.setter - def height(self, height=0.0): - self._height = height - - @property - def color(self): - return self._properties['color'] - - @color.setter - def color(self, color=(0, 0, 0, 255)): - self._properties['color'] = color - - @property - def text_color(self): - return self._properties['text_color'] - - @text_color.setter - def text_color(self, color=(100, 100, 100, 255)): - self._properties['text_color'] = color - - @property - def border_color(self): - return self._properties['border_color'] - - @border_color.setter - def border_color(self, color=(0, 0, 0, 255)): - self._properties['border_color'] = color - - @property - def disabled(self): - return self._properties['disabled'] - - @disabled.setter - def disabled(self, state=False): - self._properties['disabled'] = state - - @property - def selected(self): - if self._properties['selected'] != self.isSelected(): - self._properties['selected'] = self.isSelected() - return self._properties['selected'] - - @selected.setter - def selected(self, selected=False): - self.setSelected(selected) - - @property - def visible(self): - return self._properties['visible'] - - @visible.setter - def visible(self, visible=False): - self._properties['visible'] = visible - self.setVisible(visible) - - @property - def xy_pos(self): - """ - return the item scene postion. - ("node.pos" conflicted with "QGraphicsItem.pos()" - so it was refactored to "xy_pos".) - - Returns: - list[float]: x, y scene position. - """ - return [float(self.scenePos().x()), float(self.scenePos().y())] - - @xy_pos.setter - def xy_pos(self, pos=None): - """ - set the item scene postion. - ("node.pos" conflicted with "QGraphicsItem.pos()" - so it was refactored to "xy_pos".) - - Args: - pos (list[float]): x, y scene position. - """ - pos = pos or [0.0, 0.0] - self.setPos(pos[0], pos[1]) - - @property - def name(self): - return self._properties['name'] - - @name.setter - def name(self, name=''): - self._properties['name'] = name - self.setToolTip('node: {}'.format(name)) - - @property - def properties(self): - """ - return the node view attributes. - - Returns: - dict: {property_name: property_value} - """ - props = {'width': self.width, - 'height': self.height, - 'pos': self.xy_pos} - props.update(self._properties) - return props - - def viewer(self): - """ - return the main viewer. - - Returns: - NodeGraphQt.widgets.viewer.NodeViewer: viewer object. - """ - if self.scene(): - return self.scene().viewer() - - def delete(self): - """ - remove node view from the scene. - """ - if self.scene(): - self.scene().removeItem(self) - - def from_dict(self, node_dict): - """ - set the node view attributes from the dictionary. - - Args: - node_dict (dict): serialized node dict. - """ - node_attrs = list(self._properties.keys()) + ['width', 'height', 'pos'] - for name, value in node_dict.items(): - if name in node_attrs: - # "node.pos" conflicted with "QGraphicsItem.pos()" - # so it's refactored to "xy_pos". - if name == 'pos': - name = 'xy_pos' - setattr(self, name, value) diff --git a/cuegui/NodeGraphQt/qgraphics/node_backdrop.py b/cuegui/NodeGraphQt/qgraphics/node_backdrop.py deleted file mode 100644 index d5141b332..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_backdrop.py +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/python -from qtpy import QtGui, QtCore, QtWidgets - -from NodeGraphQt.constants import Z_VAL_BACKDROP, NodeEnum -from NodeGraphQt.qgraphics.node_abstract import AbstractNodeItem -from NodeGraphQt.qgraphics.pipe import PipeItem -from NodeGraphQt.qgraphics.port import PortItem - - -class BackdropSizer(QtWidgets.QGraphicsItem): - """ - Sizer item for resizing a backdrop item. - - Args: - parent (BackdropNodeItem): the parent node item. - size (float): sizer size. - """ - - def __init__(self, parent=None, size=6.0): - super(BackdropSizer, self).__init__(parent) - self.setFlag(self.GraphicsItemFlag.ItemIsSelectable, True) - self.setFlag(self.GraphicsItemFlag.ItemIsMovable, True) - self.setFlag(self.GraphicsItemFlag.ItemSendsScenePositionChanges, True) - self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.SizeFDiagCursor)) - self.setToolTip('double-click auto resize') - self._size = size - - @property - def size(self): - return self._size - - def set_pos(self, x, y): - x -= self._size - y -= self._size - self.setPos(x, y) - - def boundingRect(self): - return QtCore.QRectF(0.5, 0.5, self._size, self._size) - - def itemChange(self, change, value): - if change == self.GraphicsItemChange.ItemPositionChange: - item = self.parentItem() - mx, my = item.minimum_size - x = mx if value.x() < mx else value.x() - y = my if value.y() < my else value.y() - value = QtCore.QPointF(x, y) - item.on_sizer_pos_changed(value) - return value - return super(BackdropSizer, self).itemChange(change, value) - - def mouseDoubleClickEvent(self, event): - item = self.parentItem() - item.on_sizer_double_clicked() - super(BackdropSizer, self).mouseDoubleClickEvent(event) - - def mousePressEvent(self, event): - self.__prev_xy = (self.pos().x(), self.pos().y()) - super(BackdropSizer, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - current_xy = (self.pos().x(), self.pos().y()) - if current_xy != self.__prev_xy: - item = self.parentItem() - item.on_sizer_pos_mouse_release() - del self.__prev_xy - super(BackdropSizer, self).mouseReleaseEvent(event) - - def paint(self, painter, option, widget): - """ - Draws the backdrop sizer in the bottom right corner. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - painter.save() - - margin = 1.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - item = self.parentItem() - if item and item.selected: - color = QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value) - else: - color = QtGui.QColor(*item.color) - color = color.darker(110) - path = QtGui.QPainterPath() - path.moveTo(rect.topRight()) - path.lineTo(rect.bottomRight()) - path.lineTo(rect.bottomLeft()) - painter.setBrush(color) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.fillPath(path, painter.brush()) - - painter.restore() - - -class BackdropNodeItem(AbstractNodeItem): - """ - Base Backdrop item. - - Args: - name (str): name displayed on the node. - text (str): backdrop text. - parent (QtWidgets.QGraphicsItem): parent item. - """ - - def __init__(self, name='backdrop', text='', parent=None): - super(BackdropNodeItem, self).__init__(name, parent) - self.setZValue(Z_VAL_BACKDROP) - self._properties['backdrop_text'] = text - self._min_size = 80, 80 - self._sizer = BackdropSizer(self, 26.0) - self._sizer.set_pos(*self._min_size) - self._nodes = [self] - - def _combined_rect(self, nodes): - group = self.scene().createItemGroup(nodes) - rect = group.boundingRect() - self.scene().destroyItemGroup(group) - return rect - - def mouseDoubleClickEvent(self, event): - viewer = self.viewer() - if viewer: - viewer.node_double_clicked.emit(self.id) - super(BackdropNodeItem, self).mouseDoubleClickEvent(event) - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.MouseButton.LeftButton: - pos = event.scenePos() - rect = QtCore.QRectF(pos.x() - 5, pos.y() - 5, 10, 10) - item = self.scene().items(rect)[0] - - if isinstance(item, (PortItem, PipeItem)): - self.setFlag(self.GraphicsItemFlag.ItemIsMovable, False) - return - if self.selected: - return - - viewer = self.viewer() - [n.setSelected(False) for n in viewer.selected_nodes()] - - self._nodes += self.get_nodes(False) - [n.setSelected(True) for n in self._nodes] - - def mouseReleaseEvent(self, event): - super(BackdropNodeItem, self).mouseReleaseEvent(event) - self.setFlag(self.GraphicsItemFlag.ItemIsMovable, True) - [n.setSelected(True) for n in self._nodes] - self._nodes = [self] - - def on_sizer_pos_changed(self, pos): - self._width = pos.x() + self._sizer.size - self._height = pos.y() + self._sizer.size - - def on_sizer_pos_mouse_release(self): - size = { - 'pos': self.xy_pos, - 'width': self._width, - 'height': self._height} - self.viewer().node_backdrop_updated.emit( - self.id, 'sizer_mouse_release', size) - - def on_sizer_double_clicked(self): - size = self.calc_backdrop_size() - self.viewer().node_backdrop_updated.emit( - self.id, 'sizer_double_clicked', size) - - def paint(self, painter, option, widget): - """ - Draws the backdrop rect. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - painter.save() - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - - margin = 1.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - radius = 2.6 - color = (self.color[0], self.color[1], self.color[2], 50) - painter.setBrush(QtGui.QColor(*color)) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.drawRoundedRect(rect, radius, radius) - - top_rect = QtCore.QRectF(rect.x(), rect.y(), rect.width(), 26.0) - painter.setBrush(QtGui.QBrush(QtGui.QColor(*self.color))) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.drawRoundedRect(top_rect, radius, radius) - for pos in [top_rect.left(), top_rect.right() - 5.0]: - painter.drawRect( - QtCore.QRectF(pos, top_rect.bottom() - 5.0, 5.0, 5.0)) - - if self.backdrop_text: - painter.setPen(QtGui.QColor(*self.text_color)) - txt_rect = QtCore.QRectF( - top_rect.x() + 5.0, top_rect.height() + 3.0, - rect.width() - 5.0, rect.height()) - painter.setPen(QtGui.QColor(*self.text_color)) - painter.drawText(txt_rect, - QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.TextFlag.TextWordWrap, - self.backdrop_text) - - if self.selected: - sel_color = [x for x in NodeEnum.SELECTED_COLOR.value] - sel_color[-1] = 15 - painter.setBrush(QtGui.QColor(*sel_color)) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.drawRoundedRect(rect, radius, radius) - - txt_rect = QtCore.QRectF(top_rect.x(), top_rect.y(), - rect.width(), top_rect.height()) - painter.setPen(QtGui.QColor(*self.text_color)) - painter.drawText(txt_rect, QtCore.Qt.AlignmentFlag.AlignCenter, self.name) - - border = 0.8 - border_color = self.color - if self.selected and NodeEnum.SELECTED_BORDER_COLOR.value: - border = 1.0 - border_color = NodeEnum.SELECTED_BORDER_COLOR.value - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtGui.QPen(QtGui.QColor(*border_color), border)) - painter.drawRoundedRect(rect, radius, radius) - - painter.restore() - - def get_nodes(self, inc_intersects=False): - mode = {True: QtCore.Qt.ItemSelectionMode.IntersectsItemShape, - False: QtCore.Qt.ItemSelectionMode.ContainsItemShape} - nodes = [] - if self.scene(): - polygon = self.mapToScene(self.boundingRect()) - rect = polygon.boundingRect() - items = self.scene().items(rect, mode=mode[inc_intersects]) - for item in items: - if item == self or item == self._sizer: - continue - if isinstance(item, AbstractNodeItem): - nodes.append(item) - return nodes - - def calc_backdrop_size(self, nodes=None): - nodes = nodes or self.get_nodes(True) - if nodes: - nodes_rect = self._combined_rect(nodes) - else: - center = self.mapToScene(self.boundingRect().center()) - nodes_rect = QtCore.QRectF( - center.x(), center.y(), - self._min_size[0], self._min_size[1] - ) - - padding = 40 - return { - 'pos': [ - nodes_rect.x() - padding, nodes_rect.y() - padding - ], - 'width': nodes_rect.width() + (padding * 2), - 'height': nodes_rect.height() + (padding * 2) - } - - @property - def minimum_size(self): - return self._min_size - - @minimum_size.setter - def minimum_size(self, size=(50, 50)): - self._min_size = size - - @property - def backdrop_text(self): - return self._properties['backdrop_text'] - - @backdrop_text.setter - def backdrop_text(self, text): - self._properties['backdrop_text'] = text - self.update(self.boundingRect()) - - @AbstractNodeItem.width.setter - def width(self, width=0.0): - AbstractNodeItem.width.fset(self, width) - self._sizer.set_pos(self._width, self._height) - - @AbstractNodeItem.height.setter - def height(self, height=0.0): - AbstractNodeItem.height.fset(self, height) - self._sizer.set_pos(self._width, self._height) - - def from_dict(self, node_dict): - super().from_dict(node_dict) - custom_props = node_dict.get('custom') or {} - for prop_name, value in custom_props.items(): - if prop_name == 'backdrop_text': - self.backdrop_text = value diff --git a/cuegui/NodeGraphQt/qgraphics/node_base.py b/cuegui/NodeGraphQt/qgraphics/node_base.py deleted file mode 100644 index f149dcee0..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_base.py +++ /dev/null @@ -1,1056 +0,0 @@ -#!/usr/bin/python -from collections import OrderedDict - -from qtpy import QtGui, QtCore, QtWidgets - -from NodeGraphQt.constants import ( - ITEM_CACHE_MODE, - ICON_NODE_BASE, - LayoutDirectionEnum, - NodeEnum, - PortEnum, - PortTypeEnum, - Z_VAL_NODE -) -from NodeGraphQt.errors import NodeWidgetError -from NodeGraphQt.qgraphics.node_abstract import AbstractNodeItem -from NodeGraphQt.qgraphics.node_overlay_disabled import XDisabledItem -from NodeGraphQt.qgraphics.node_text_item import NodeTextItem -from NodeGraphQt.qgraphics.port import PortItem, CustomPortItem - - -class NodeItem(AbstractNodeItem): - """ - Base Node item. - - Args: - name (str): name displayed on the node. - parent (QtWidgets.QGraphicsItem): parent item. - """ - - def __init__(self, name='node', parent=None): - super(NodeItem, self).__init__(name, parent) - pixmap = QtGui.QPixmap(ICON_NODE_BASE) - if pixmap.size().height() > NodeEnum.ICON_SIZE.value: - pixmap = pixmap.scaledToHeight( - NodeEnum.ICON_SIZE.value, - QtCore.Qt.TransformationMode.SmoothTransformation - ) - self._properties['icon'] = ICON_NODE_BASE - self._icon_item = QtWidgets.QGraphicsPixmapItem(pixmap, self) - self._icon_item.setTransformationMode(QtCore.Qt.TransformationMode.SmoothTransformation) - self._text_item = NodeTextItem(self.name, self) - self._x_item = XDisabledItem(self, 'DISABLED') - self._input_items = OrderedDict() - self._output_items = OrderedDict() - self._widgets = OrderedDict() - self._proxy_mode = False - self._proxy_mode_threshold = 70 - - def post_init(self, viewer, pos=None): - """ - Called after node has been added into the scene. - - Args: - viewer (NodeGraphQt.widgets.viewer.NodeViewer): main viewer - pos (tuple): the cursor pos if node is called with tab search. - """ - if self.layout_direction == LayoutDirectionEnum.VERTICAL.value: - font = QtGui.QFont() - font.setPointSize(15) - self.text_item.setFont(font) - - # hide port text items for vertical layout. - if self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - for text_item in self._input_items.values(): - text_item.setVisible(False) - for text_item in self._output_items.values(): - text_item.setVisible(False) - - def _paint_horizontal(self, painter, option, widget): - painter.save() - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - - # base background. - margin = 1.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - radius = 4.0 - painter.setBrush(QtGui.QColor(*self.color)) - painter.drawRoundedRect(rect, radius, radius) - - # light overlay on background when selected. - if self.selected: - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - painter.drawRoundedRect(rect, radius, radius) - - # node name background. - padding = 3.0, 2.0 - text_rect = self._text_item.boundingRect() - text_rect = QtCore.QRectF(text_rect.x() + padding[0], - rect.y() + padding[1], - rect.width() - padding[0] - margin, - text_rect.height() - (padding[1] * 2)) - if self.selected: - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - painter.setBrush(QtGui.QColor(0, 0, 0, 80)) - painter.drawRoundedRect(text_rect, 3.0, 3.0) - - # node border - if self.selected: - border_width = 1.2 - border_color = QtGui.QColor( - *NodeEnum.SELECTED_BORDER_COLOR.value - ) - else: - border_width = 0.8 - border_color = QtGui.QColor(*self.border_color) - - border_rect = QtCore.QRectF(rect.left(), rect.top(), - rect.width(), rect.height()) - - pen = QtGui.QPen(border_color, border_width) - pen.setCosmetic(self.viewer().get_zoom() < 0.0) - path = QtGui.QPainterPath() - path.addRoundedRect(border_rect, radius, radius) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(pen) - painter.drawPath(path) - - painter.restore() - - def _paint_vertical(self, painter, option, widget): - painter.save() - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - - # base background. - margin = 1.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - radius = 4.0 - painter.setBrush(QtGui.QColor(*self.color)) - painter.drawRoundedRect(rect, radius, radius) - - # light overlay on background when selected. - if self.selected: - painter.setBrush( - QtGui.QColor(*NodeEnum.SELECTED_COLOR.value) - ) - painter.drawRoundedRect(rect, radius, radius) - - # top & bottom edge background. - padding = 2.0 - height = 10 - if self.selected: - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - painter.setBrush(QtGui.QColor(0, 0, 0, 80)) - for y in [rect.y() + padding, rect.height() - height - 1]: - edge_rect = QtCore.QRectF(rect.x() + padding, y, - rect.width() - (padding * 2), height) - painter.drawRoundedRect(edge_rect, 3.0, 3.0) - - # node border - border_width = 0.8 - border_color = QtGui.QColor(*self.border_color) - if self.selected: - border_width = 1.2 - border_color = QtGui.QColor( - *NodeEnum.SELECTED_BORDER_COLOR.value - ) - border_rect = QtCore.QRectF(rect.left(), rect.top(), - rect.width(), rect.height()) - - pen = QtGui.QPen(border_color, border_width) - pen.setCosmetic(self.viewer().get_zoom() < 0.0) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(pen) - painter.drawRoundedRect(border_rect, radius, radius) - - painter.restore() - - def paint(self, painter, option, widget): - """ - Draws the node base not the ports. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - self.auto_switch_mode() - if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - self._paint_horizontal(painter, option, widget) - elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - self._paint_vertical(painter, option, widget) - else: - raise RuntimeError('Node graph layout direction not valid!') - - def mousePressEvent(self, event): - """ - Re-implemented to ignore event if LMB is over port collision area. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. - """ - if event.button() == QtCore.Qt.MouseButton.LeftButton: - for p in self._input_items.keys(): - if p.hovered: - event.ignore() - return - for p in self._output_items.keys(): - if p.hovered: - event.ignore() - return - super(NodeItem, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - """ - Re-implemented to ignore event if Alt modifier is pressed. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. - """ - if event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier: - event.ignore() - return - super(NodeItem, self).mouseReleaseEvent(event) - - def mouseDoubleClickEvent(self, event): - """ - Re-implemented to emit "node_double_clicked" signal. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. - """ - if event.button() == QtCore.Qt.MouseButton.LeftButton: - if not self.disabled: - # enable text item edit mode. - items = self.scene().items(event.scenePos()) - if self._text_item in items: - self._text_item.set_editable(True) - self._text_item.setFocus() - event.ignore() - return - - viewer = self.viewer() - if viewer: - viewer.node_double_clicked.emit(self.id) - super(NodeItem, self).mouseDoubleClickEvent(event) - - def itemChange(self, change, value): - """ - Re-implemented to update pipes on selection changed. - - Args: - change: - value: - """ - if change == self.GraphicsItemChange.ItemSelectedChange and self.scene(): - self.reset_pipes() - if value: - self.highlight_pipes() - self.setZValue(Z_VAL_NODE) - if not self.selected: - self.setZValue(Z_VAL_NODE + 1) - - return super(NodeItem, self).itemChange(change, value) - - def _tooltip_disable(self, state): - """ - Updates the node tooltip when the node is enabled/disabled. - - Args: - state (bool): node disable state. - """ - tooltip = '{}'.format(self.name) - if state: - tooltip += ' (DISABLED)' - tooltip += '
    {}
    '.format(self.type_) - self.setToolTip(tooltip) - - def _set_base_size(self, add_w=0.0, add_h=0.0): - """ - Sets the initial base size for the node. - - Args: - add_w (float): add additional width. - add_h (float): add additional height. - """ - self._width, self._height = self.calc_size(add_w, add_h) - if self._width < NodeEnum.WIDTH.value: - self._width = NodeEnum.WIDTH.value - if self._height < NodeEnum.HEIGHT.value: - self._height = NodeEnum.HEIGHT.value - - def _set_text_color(self, color): - """ - set text color. - - Args: - color (tuple): color value in (r, g, b, a). - """ - text_color = QtGui.QColor(*color) - for port, text in self._input_items.items(): - text.setDefaultTextColor(text_color) - for port, text in self._output_items.items(): - text.setDefaultTextColor(text_color) - self._text_item.setDefaultTextColor(text_color) - - def activate_pipes(self): - """ - active pipe color. - """ - ports = self.inputs + self.outputs - for port in ports: - for pipe in port.connected_pipes: - pipe.activate() - - def highlight_pipes(self): - """ - Highlight pipe color. - """ - ports = self.inputs + self.outputs - for port in ports: - for pipe in port.connected_pipes: - pipe.highlight() - - def reset_pipes(self): - """ - Reset all the pipe colors. - """ - ports = self.inputs + self.outputs - for port in ports: - for pipe in port.connected_pipes: - pipe.reset() - - def _calc_size_horizontal(self): - # width, height from node name text. - text_w = self._text_item.boundingRect().width() - text_h = self._text_item.boundingRect().height() - - # width, height from node ports. - port_width = 0.0 - p_input_text_width = 0.0 - p_output_text_width = 0.0 - p_input_height = 0.0 - p_output_height = 0.0 - for port, text in self._input_items.items(): - if not port.isVisible(): - continue - if not port_width: - port_width = port.boundingRect().width() - t_width = text.boundingRect().width() - if text.isVisible() and t_width > p_input_text_width: - p_input_text_width = text.boundingRect().width() - p_input_height += port.boundingRect().height() - for port, text in self._output_items.items(): - if not port.isVisible(): - continue - if not port_width: - port_width = port.boundingRect().width() - t_width = text.boundingRect().width() - if text.isVisible() and t_width > p_output_text_width: - p_output_text_width = text.boundingRect().width() - p_output_height += port.boundingRect().height() - - port_text_width = p_input_text_width + p_output_text_width - - # width, height from node embedded widgets. - widget_width = 0.0 - widget_height = 0.0 - for widget in self._widgets.values(): - if not widget.isVisible(): - continue - w_width = widget.boundingRect().width() - w_height = widget.boundingRect().height() - if w_width > widget_width: - widget_width = w_width - widget_height += w_height - - side_padding = 0.0 - if all([widget_width, p_input_text_width, p_output_text_width]): - port_text_width = max([p_input_text_width, p_output_text_width]) - port_text_width *= 2 - elif widget_width: - side_padding = 10 - - width = port_width + max([text_w, port_text_width]) + side_padding - height = max([text_h, p_input_height, p_output_height, widget_height]) - if widget_width: - # add additional width for node widget. - width += widget_width - if widget_height: - # add bottom margin for node widget. - height += 4.0 - height *= 1.05 - return width, height - - def _calc_size_vertical(self): - p_input_width = 0.0 - p_output_width = 0.0 - p_input_height = 0.0 - p_output_height = 0.0 - for port in self._input_items.keys(): - if port.isVisible(): - p_input_width += port.boundingRect().width() - if not p_input_height: - p_input_height = port.boundingRect().height() - for port in self._output_items.keys(): - if port.isVisible(): - p_output_width += port.boundingRect().width() - if not p_output_height: - p_output_height = port.boundingRect().height() - - widget_width = 0.0 - widget_height = 0.0 - for widget in self._widgets.values(): - if not widget.isVisible(): - continue - if widget.boundingRect().width() > widget_width: - widget_width = widget.boundingRect().width() - widget_height += widget.boundingRect().height() - - width = max([p_input_width, p_output_width, widget_width]) - height = p_input_height + p_output_height + widget_height - return width, height - - def calc_size(self, add_w=0.0, add_h=0.0): - """ - Calculates the minimum node size. - - Args: - add_w (float): additional width. - add_h (float): additional height. - - Returns: - tuple(float, float): width, height. - """ - if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - width, height = self._calc_size_horizontal() - elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - width, height = self._calc_size_vertical() - else: - raise RuntimeError('Node graph layout direction not valid!') - - # additional width, height. - width += add_w - height += add_h - return width, height - - def _align_icon_horizontal(self, h_offset, v_offset): - icon_rect = self._icon_item.boundingRect() - text_rect = self._text_item.boundingRect() - x = self.boundingRect().left() + 2.0 - y = text_rect.center().y() - (icon_rect.height() / 2) - self._icon_item.setPos(x + h_offset, y + v_offset) - - def _align_icon_vertical(self, h_offset, v_offset): - center_y = self.boundingRect().center().y() - icon_rect = self._icon_item.boundingRect() - text_rect = self._text_item.boundingRect() - x = self.boundingRect().right() + h_offset - y = center_y - text_rect.height() - (icon_rect.height() / 2) + v_offset - self._icon_item.setPos(x, y) - - def align_icon(self, h_offset=0.0, v_offset=0.0): - """ - Align node icon to the default top left of the node. - - Args: - v_offset (float): additional vertical offset. - h_offset (float): additional horizontal offset. - """ - if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - self._align_icon_horizontal(h_offset, v_offset) - elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - self._align_icon_vertical(h_offset, v_offset) - else: - raise RuntimeError('Node graph layout direction not valid!') - - def _align_label_horizontal(self, h_offset, v_offset): - rect = self.boundingRect() - text_rect = self._text_item.boundingRect() - x = rect.center().x() - (text_rect.width() / 2) - self._text_item.setPos(x + h_offset, rect.y() + v_offset) - - def _align_label_vertical(self, h_offset, v_offset): - rect = self._text_item.boundingRect() - x = self.boundingRect().right() + h_offset - y = self.boundingRect().center().y() - (rect.height() / 2) + v_offset - self.text_item.setPos(x, y) - - def align_label(self, h_offset=0.0, v_offset=0.0): - """ - Center node label text to the top of the node. - - Args: - v_offset (float): vertical offset. - h_offset (float): horizontal offset. - """ - if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - self._align_label_horizontal(h_offset, v_offset) - elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - self._align_label_vertical(h_offset, v_offset) - else: - raise RuntimeError('Node graph layout direction not valid!') - - def _align_widgets_horizontal(self, v_offset): - if not self._widgets: - return - rect = self.boundingRect() - y = rect.y() + v_offset - inputs = [p for p in self.inputs if p.isVisible()] - outputs = [p for p in self.outputs if p.isVisible()] - for widget in self._widgets.values(): - if not widget.isVisible(): - continue - widget_rect = widget.boundingRect() - if not inputs: - x = rect.left() + 10 - widget.widget().setTitleAlign('left') - elif not outputs: - x = rect.right() - widget_rect.width() - 10 - widget.widget().setTitleAlign('right') - else: - x = rect.center().x() - (widget_rect.width() / 2) - widget.widget().setTitleAlign('center') - widget.setPos(x, y) - y += widget_rect.height() - - def _align_widgets_vertical(self, v_offset): - if not self._widgets: - return - rect = self.boundingRect() - y = rect.center().y() + v_offset - widget_height = 0.0 - for widget in self._widgets.values(): - if not widget.isVisible(): - continue - widget_rect = widget.boundingRect() - widget_height += widget_rect.height() - y -= widget_height / 2 - - for widget in self._widgets.values(): - if not widget.isVisible(): - continue - widget_rect = widget.boundingRect() - x = rect.center().x() - (widget_rect.width() / 2) - widget.widget().setTitleAlign('center') - widget.setPos(x, y) - y += widget_rect.height() - - def align_widgets(self, v_offset=0.0): - """ - Align node widgets to the default center of the node. - - Args: - v_offset (float): vertical offset. - """ - if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - self._align_widgets_horizontal(v_offset) - elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - self._align_widgets_vertical(v_offset) - else: - raise RuntimeError('Node graph layout direction not valid!') - - def _align_ports_horizontal(self, v_offset): - width = self._width - txt_offset = PortEnum.CLICK_FALLOFF.value - 2 - spacing = 1 - - # adjust input position - inputs = [p for p in self.inputs if p.isVisible()] - if inputs: - port_width = inputs[0].boundingRect().width() - port_height = inputs[0].boundingRect().height() - port_x = (port_width / 2) * -1 - port_y = v_offset - for port in inputs: - port.setPos(port_x, port_y) - port_y += port_height + spacing - # adjust input text position - for port, text in self._input_items.items(): - if port.isVisible(): - txt_x = port.boundingRect().width() / 2 - txt_offset - text.setPos(txt_x, port.y() - 1.5) - - # adjust output position - outputs = [p for p in self.outputs if p.isVisible()] - if outputs: - port_width = outputs[0].boundingRect().width() - port_height = outputs[0].boundingRect().height() - port_x = width - (port_width / 2) - port_y = v_offset - for port in outputs: - port.setPos(port_x, port_y) - port_y += port_height + spacing - # adjust output text position - for port, text in self._output_items.items(): - if port.isVisible(): - txt_width = text.boundingRect().width() - txt_offset - txt_x = port.x() - txt_width - text.setPos(txt_x, port.y() - 1.5) - - def _align_ports_vertical(self, v_offset): - # adjust input position - inputs = [p for p in self.inputs if p.isVisible()] - if inputs: - port_width = inputs[0].boundingRect().width() - port_height = inputs[0].boundingRect().height() - half_width = port_width / 2 - delta = self._width / (len(inputs) + 1) - port_x = delta - port_y = (port_height / 2) * -1 - for port in inputs: - port.setPos(port_x - half_width, port_y) - port_x += delta - - # adjust output position - outputs = [p for p in self.outputs if p.isVisible()] - if outputs: - port_width = outputs[0].boundingRect().width() - port_height = outputs[0].boundingRect().height() - half_width = port_width / 2 - delta = self._width / (len(outputs) + 1) - port_x = delta - port_y = self._height - (port_height / 2) - for port in outputs: - port.setPos(port_x - half_width, port_y) - port_x += delta - - def align_ports(self, v_offset=0.0): - """ - Align input, output ports in the node layout. - - Args: - v_offset (float): port vertical offset. - """ - if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - self._align_ports_horizontal(v_offset) - elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - self._align_ports_vertical(v_offset) - else: - raise RuntimeError('Node graph layout direction not valid!') - - def _draw_node_horizontal(self): - height = self._text_item.boundingRect().height() + 4.0 - - # update port text items in visibility. - for port, text in self._input_items.items(): - if port.isVisible(): - text.setVisible(port.display_name) - for port, text in self._output_items.items(): - if port.isVisible(): - text.setVisible(port.display_name) - - # setup initial base size. - self._set_base_size(add_h=height) - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- set the initial node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label() - # align icon - self.align_icon(h_offset=2.0, v_offset=1.0) - # arrange input and output ports. - self.align_ports(v_offset=height) - # arrange node widgets - self.align_widgets(v_offset=height) - - self.update() - - def _draw_node_vertical(self): - # hide the port text items in vertical layout. - for port, text in self._input_items.items(): - text.setVisible(False) - for port, text in self._output_items.items(): - text.setVisible(False) - - # setup initial base size. - self._set_base_size() - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- setup node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label(h_offset=6) - # align icon - self.align_icon(h_offset=6, v_offset=4) - # arrange input and output ports. - self.align_ports() - # arrange node widgets - self.align_widgets() - - self.update() - - def draw_node(self): - """ - Re-draw the node item in the scene with proper - calculated size and widgets aligned. - """ - if self.layout_direction is LayoutDirectionEnum.HORIZONTAL.value: - self._draw_node_horizontal() - elif self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - self._draw_node_vertical() - else: - raise RuntimeError('Node graph layout direction not valid!') - - def post_init(self, viewer=None, pos=None): - """ - Called after node has been added into the scene. - Adjust the node layout and form after the node has been added. - - Args: - viewer (NodeGraphQt.widgets.viewer.NodeViewer): not used - pos (tuple): cursor position. - """ - self.draw_node() - - # set initial node position. - if pos: - self.xy_pos = pos - - def auto_switch_mode(self): - """ - Decide whether to draw the node with proxy mode. - (this is called at the start in the "self.paint()" function.) - """ - if ITEM_CACHE_MODE is QtWidgets.QGraphicsItem.ItemCoordinateCache: - return - - rect = self.sceneBoundingRect() - l = self.viewer().mapToGlobal( - self.viewer().mapFromScene(rect.topLeft())) - r = self.viewer().mapToGlobal( - self.viewer().mapFromScene(rect.topRight())) - # width is the node width in screen - width = r.x() - l.x() - - self.set_proxy_mode(width < self._proxy_mode_threshold) - - def set_proxy_mode(self, mode): - """ - Set whether to draw the node with proxy mode. - (proxy mode toggles visibility for some qgraphic items in the node.) - - Args: - mode (bool): true to enable proxy mode. - """ - if mode is self._proxy_mode: - return - self._proxy_mode = mode - - visible = not mode - - # disable overlay item. - self._x_item.proxy_mode = self._proxy_mode - - # node widget visibility. - for w in self._widgets.values(): - w.widget().setVisible(visible) - - # port text is not visible in vertical layout. - if self.layout_direction is LayoutDirectionEnum.VERTICAL.value: - port_text_visible = False - else: - port_text_visible = visible - - # input port text visibility. - for port, text in self._input_items.items(): - if port.display_name: - text.setVisible(port_text_visible) - - # output port text visibility. - for port, text in self._output_items.items(): - if port.display_name: - text.setVisible(port_text_visible) - - self._text_item.setVisible(visible) - self._icon_item.setVisible(visible) - - @property - def icon(self): - return self._properties['icon'] - - @icon.setter - def icon(self, path=None): - self._properties['icon'] = path - path = path or ICON_NODE_BASE - pixmap = QtGui.QPixmap(path) - if pixmap.size().height() > NodeEnum.ICON_SIZE.value: - pixmap = pixmap.scaledToHeight( - NodeEnum.ICON_SIZE.value, - QtCore.Qt.TransformationMode.SmoothTransformation - ) - self._icon_item.setPixmap(pixmap) - if self.scene(): - self.post_init() - - self.update() - - @AbstractNodeItem.layout_direction.setter - def layout_direction(self, value=0): - AbstractNodeItem.layout_direction.fset(self, value) - self.draw_node() - - @AbstractNodeItem.width.setter - def width(self, width=0.0): - w, h = self.calc_size() - width = width if width > w else w - AbstractNodeItem.width.fset(self, width) - - @AbstractNodeItem.height.setter - def height(self, height=0.0): - w, h = self.calc_size() - h = 70 if h < 70 else h - height = height if height > h else h - AbstractNodeItem.height.fset(self, height) - - @AbstractNodeItem.disabled.setter - def disabled(self, state=False): - AbstractNodeItem.disabled.fset(self, state) - for n, w in self._widgets.items(): - w.widget().setDisabled(state) - self._tooltip_disable(state) - self._x_item.setVisible(state) - - @AbstractNodeItem.selected.setter - def selected(self, selected=False): - AbstractNodeItem.selected.fset(self, selected) - if selected: - self.highlight_pipes() - - @AbstractNodeItem.name.setter - def name(self, name=''): - AbstractNodeItem.name.fset(self, name) - if name == self._text_item.toPlainText(): - return - self._text_item.setPlainText(name) - if self.scene(): - self.align_label() - self.update() - - @AbstractNodeItem.color.setter - def color(self, color=(100, 100, 100, 255)): - AbstractNodeItem.color.fset(self, color) - if self.scene(): - self.scene().update() - self.update() - - @AbstractNodeItem.text_color.setter - def text_color(self, color=(100, 100, 100, 255)): - AbstractNodeItem.text_color.fset(self, color) - self._set_text_color(color) - self.update() - - @property - def text_item(self): - """ - Get the node name text qgraphics item. - - Returns: - NodeTextItem: node text object. - """ - return self._text_item - - @property - def icon_item(self): - """ - Get the node icon qgraphics item. - - Returns: - QtWidgets.QGraphicsPixmapItem: node icon object. - """ - return self._icon_item - - @property - def inputs(self): - """ - Returns: - list[PortItem]: input port graphic items. - """ - return list(self._input_items.keys()) - - @property - def outputs(self): - """ - Returns: - list[PortItem]: output port graphic items. - """ - return list(self._output_items.keys()) - - def _add_port(self, port): - """ - Adds a port qgraphics item into the node. - - Args: - port (PortItem): port item. - - Returns: - PortItem: port qgraphics item. - """ - text = QtWidgets.QGraphicsTextItem(port.name, self) - text.font().setPointSize(8) - text.setFont(text.font()) - text.setVisible(port.display_name) - text.setCacheMode(ITEM_CACHE_MODE) - if port.port_type == PortTypeEnum.IN.value: - self._input_items[port] = text - elif port.port_type == PortTypeEnum.OUT.value: - self._output_items[port] = text - if self.scene(): - self.post_init() - return port - - def add_input(self, name='input', multi_port=False, display_name=True, - locked=False, painter_func=None): - """ - Adds a port qgraphics item into the node with the "port_type" set as - IN_PORT. - - Args: - name (str): name for the port. - multi_port (bool): allow multiple connections. - display_name (bool): display the port name. - locked (bool): locked state. - painter_func (function): custom paint function. - - Returns: - PortItem: input port qgraphics item. - """ - if painter_func: - port = CustomPortItem(self, painter_func) - else: - port = PortItem(self) - port.name = name - port.port_type = PortTypeEnum.IN.value - port.multi_connection = multi_port - port.display_name = display_name - port.locked = locked - return self._add_port(port) - - def add_output(self, name='output', multi_port=False, display_name=True, - locked=False, painter_func=None): - """ - Adds a port qgraphics item into the node with the "port_type" set as - OUT_PORT. - - Args: - name (str): name for the port. - multi_port (bool): allow multiple connections. - display_name (bool): display the port name. - locked (bool): locked state. - painter_func (function): custom paint function. - - Returns: - PortItem: output port qgraphics item. - """ - if painter_func: - port = CustomPortItem(self, painter_func) - else: - port = PortItem(self) - port.name = name - port.port_type = PortTypeEnum.OUT.value - port.multi_connection = multi_port - port.display_name = display_name - port.locked = locked - return self._add_port(port) - - def _delete_port(self, port, text): - """ - Removes port item and port text from node. - - Args: - port (PortItem): port object. - text (QtWidgets.QGraphicsTextItem): port text object. - """ - port.setParentItem(None) - text.setParentItem(None) - self.scene().removeItem(port) - self.scene().removeItem(text) - del port - del text - - def delete_input(self, port): - """ - Remove input port from node. - - Args: - port (PortItem): port object. - """ - self._delete_port(port, self._input_items.pop(port)) - - def delete_output(self, port): - """ - Remove output port from node. - - Args: - port (PortItem): port object. - """ - self._delete_port(port, self._output_items.pop(port)) - - def get_input_text_item(self, port_item): - """ - Args: - port_item (PortItem): port item. - - Returns: - QGraphicsTextItem: graphic item used for the port text. - """ - return self._input_items[port_item] - - def get_output_text_item(self, port_item): - """ - Args: - port_item (PortItem): port item. - - Returns: - QGraphicsTextItem: graphic item used for the port text. - """ - return self._output_items[port_item] - - @property - def widgets(self): - return self._widgets.copy() - - def add_widget(self, widget): - self._widgets[widget.get_name()] = widget - - def get_widget(self, name): - widget = self._widgets.get(name) - if widget: - return widget - raise NodeWidgetError('node has no widget "{}"'.format(name)) - - def has_widget(self, name): - return name in self._widgets.keys() - - def from_dict(self, node_dict): - super(NodeItem, self).from_dict(node_dict) - custom_prop = node_dict.get('custom') or {} - for prop_name, value in custom_prop.items(): - prop_widget = self._widgets.get(prop_name) - if prop_widget: - prop_widget.set_value(value) diff --git a/cuegui/NodeGraphQt/qgraphics/node_circle.py b/cuegui/NodeGraphQt/qgraphics/node_circle.py deleted file mode 100644 index 2554b93fb..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_circle.py +++ /dev/null @@ -1,532 +0,0 @@ -#!/usr/bin/python -from qtpy import QtCore, QtGui, QtWidgets - -from NodeGraphQt.constants import NodeEnum, PortEnum -from NodeGraphQt.qgraphics.node_base import NodeItem - - -class CircleNodeItem(NodeItem): - """ - Circle Node item. - - Args: - name (str): name displayed on the node. - parent (QtWidgets.QGraphicsItem): parent item. - """ - - def __init__(self, name='circle', parent=None): - super(CircleNodeItem, self).__init__(name, parent) - - def _align_ports_horizontal(self, v_offset): - width = self._width - txt_offset = PortEnum.CLICK_FALLOFF.value - 2 - spacing = 1 - - node_center_y = self.boundingRect().center().y() - node_center_y += v_offset - - # adjust input position - inputs = [p for p in self.inputs if p.isVisible()] - if inputs: - port_width = inputs[0].boundingRect().width() - port_height = inputs[0].boundingRect().height() - - count = len(inputs) - if count >= 2: - is_odd = bool(count % 2) - middle_idx = int(count / 2) - - # top half - port_x = (port_width / 2) * -1 - port_y = node_center_y - (port_height / 2) - for idx, port in enumerate(reversed(inputs[:middle_idx])): - if idx == 0: - if is_odd: - port_x += (port_width / 2) - (txt_offset / 2) - port_y -= port_height + spacing - else: - port_y -= (port_height / 2) + spacing - port.setPos(port_x, port_y) - port_x += (port_width / 2) - (txt_offset / 2) - port_y -= port_height + spacing - - # bottom half - port_x = (port_width / 2) * -1 - port_y = node_center_y - (port_height / 2) - for idx, port in enumerate(inputs[middle_idx:]): - if idx == 0: - if not is_odd: - port_y += (port_height / 2) + spacing - port.setPos(port_x, port_y) - port_x += (port_width / 2) - (txt_offset / 2) - port_y += port_height + spacing - else: - port_x = (port_width / 2) * -1 - port_y = node_center_y - (port_height / 2) - inputs[0].setPos(port_x, port_y) - - # adjust input text position - for port, text in self._input_items.items(): - if port.isVisible(): - port_width = port.boundingRect().width() - txt_x = port.pos().x() + port_width - txt_offset - text.setPos(txt_x, port.y() - 1.5) - - # adjust output position - outputs = [p for p in self.outputs if p.isVisible()] - if outputs: - port_width = outputs[0].boundingRect().width() - port_height = outputs[0].boundingRect().height() - - count = len(outputs) - if count >= 2: - is_odd = bool(count % 2) - middle_idx = int(count / 2) - - # top half - port_x = width - (port_width / 2) - port_y = node_center_y - (port_height / 2) - for idx, port in enumerate(reversed(outputs[:middle_idx])): - if idx == 0: - if is_odd: - port_x -= (port_width / 2) - (txt_offset / 2) - port_y -= port_height + spacing - else: - port_y -= (port_height / 2) + spacing - port.setPos(port_x, port_y) - port_x -= (port_width / 2) - (txt_offset / 2) - port_y -= port_height + spacing - - # bottom half - port_x = width - (port_width / 2) - port_y = node_center_y - (port_height / 2) - for idx, port in enumerate(outputs[middle_idx:]): - if idx == 0: - if not is_odd: - port_y += (port_width / 2) - (txt_offset / 2) - port.setPos(port_x, port_y) - port_x -= (port_width / 2) - (txt_offset / 2) - port_y += port_height + spacing - else: - port_x = width - (port_width / 2) - port_y = node_center_y - (port_height / 2) - outputs[0].setPos(port_x, port_y) - - # adjust output text position - for port, text in self._output_items.items(): - if port.isVisible(): - txt_width = text.boundingRect().width() - txt_offset - txt_x = port.x() - txt_width - text.setPos(txt_x, port.y() - 1.5) - - def _align_ports_vertical(self, v_offset): - height = self._height - node_center_x = self.boundingRect().center().x() + v_offset - - # adjust input position - inputs = [p for p in self.inputs if p.isVisible()] - if inputs: - port_width = inputs[0].boundingRect().width() - port_height = inputs[0].boundingRect().height() - - count = len(inputs) - if count > 2: - is_odd = bool(count % 2) - middle_idx = int(count / 2) - - delta = (self._width / (len(inputs) + 1)) / 2 - - # left half - port_x = node_center_x - (port_width / 2) - port_y = (port_height / 2) * -1 - for idx, port in enumerate(reversed(inputs[:middle_idx])): - if idx == 0: - if is_odd: - port_x -= (port_width / 2) + delta - port_y += (port_height / 2) - else: - port_x -= delta - port.setPos(port_x, port_y) - port_x -= (port_width / 2) + delta - port_y += (port_height / 2) - - # right half - port_x = node_center_x - (port_width / 2) - port_y = (port_height / 2) * -1 - for idx, port in enumerate(inputs[middle_idx:]): - if idx == 0: - if not is_odd: - port_x += delta - port.setPos(port_x, port_y) - port_x += (port_width / 2) + delta - port_y += (port_height / 2) - - # adjust output position - outputs = [p for p in self.outputs if p.isVisible()] - if outputs: - port_width = outputs[0].boundingRect().width() - port_height = outputs[0].boundingRect().height() - - count = len(outputs) - if count > 2: - is_odd = bool(count % 2) - middle_idx = int(count / 2) - - delta = (self._width / (len(outputs) + 1)) / 2 - - # left half - port_x = node_center_x - (port_width / 2) - port_y = height - (port_height / 2) - for idx, port in enumerate(reversed(outputs[:middle_idx])): - if idx == 0: - if is_odd: - port_x -= (port_width / 2) + delta - port_y -= (port_height / 2) - else: - port_x -= delta - port.setPos(port_x, port_y) - port_x -= (port_width / 2) + delta - port_y -= (port_height / 2) - - # right half - port_x = node_center_x - (port_width / 2) - port_y = height - (port_height / 2) - for idx, port in enumerate(outputs[middle_idx:]): - if idx == 0: - if not is_odd: - port_x += delta - port.setPos(port_x, port_y) - port_x += (port_width / 2) + delta - port_y -= (port_height / 2) - - def _paint_horizontal(self, painter, option, widget): - painter.save() - - # display node area for debugging - # ---------------------------------------------------------------------- - # pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 80), 0.8) - # pen.setStyle(QtCore.Qt.DotLine) - # painter.setPen(pen) - # painter.drawRect(self.boundingRect()) - # ---------------------------------------------------------------------- - - text_rect = self._text_item.boundingRect() - text_width = text_rect.width() - if text_width < 20.0: - text_width = 20.0 - - text_rect = QtCore.QRectF( - self.boundingRect().center().x() - (text_width / 2), - self.boundingRect().center().y() - (text_rect.height() / 2), - text_rect.width(), - text_rect.height() - ) - - padding = 10.0 - rect = QtCore.QRectF( - text_rect.center().x() - (text_rect.width() / 2) - (padding / 2), - text_rect.center().y() - (text_rect.width() / 2) - (padding / 2), - text_rect.width() + padding, - text_rect.width() + padding - ) - - # draw port lines. - pen_color = QtGui.QColor(*self.border_color) - pen_color.setAlpha(120) - pen = QtGui.QPen(pen_color, 1.5) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - for p in self.inputs: - if p.isVisible(): - p_text = self.get_input_text_item(p) - if p_text.isVisible(): - pt_width = p_text.boundingRect().width() * 1.2 - else: - pt_width = p.boundingRect().width() / 4 - pt1 = QtCore.QPointF( - p.pos().x() + (p.boundingRect().width() / 2) + pt_width, - p.pos().y() + (p.boundingRect().height() / 2) - ) - path = QtGui.QPainterPath() - path.moveTo(pt1) - # path.lineTo(QtCore.QPointF(pt1.x() + 4.0, pt1.y())) - path.lineTo(rect.center()) - painter.drawPath(path) - - for p in self.outputs: - if p.isVisible(): - p_text = self.get_output_text_item(p) - if p_text.isVisible(): - pt_width = p_text.boundingRect().width() * 1.2 - else: - pt_width = p.boundingRect().width() / 4 - pt1 = QtCore.QPointF( - p.pos().x() + (p.boundingRect().width() / 2) - pt_width, - p.pos().y() + (p.boundingRect().height() / 2) - ) - path = QtGui.QPainterPath() - path.moveTo(pt1) - # path.lineTo(QtCore.QPointF(pt1.x() - 2.0, pt1.y())) - path.lineTo(rect.center()) - painter.drawPath(path) - - # draw the base color. - painter.setBrush(QtGui.QColor(*self.color)) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.drawEllipse(rect) - - # draw outline. - if self.selected: - # light overlay on background when selected. - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - painter.drawEllipse(rect) - - border_width = 1.2 - border_color = QtGui.QColor( - *NodeEnum.SELECTED_BORDER_COLOR.value - ) - else: - border_width = 0.8 - border_color = QtGui.QColor(*self.border_color) - - # draw the outlines. - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtGui.QPen(border_color, border_width)) - painter.drawEllipse(rect) - - # node name background. - text_rect = self._text_item.boundingRect() - text_rect = QtCore.QRectF( - rect.center().x() - (text_rect.width() / 2), - rect.center().y() - (text_rect.height() / 2), - text_rect.width(), - text_rect.height() - ) - if self.selected: - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - painter.setBrush(QtGui.QColor(0, 0, 0, 80)) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.drawRoundedRect(text_rect, 8.0, 8.0) - - painter.restore() - - def _paint_vertical(self, painter, option, widget): - painter.save() - - # display node area for debugging - # ---------------------------------------------------------------------- - # pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 80), 0.8) - # pen.setStyle(QtCore.Qt.DotLine) - # painter.setPen(pen) - # painter.drawRect(self.boundingRect()) - # ---------------------------------------------------------------------- - - rect = self.boundingRect() - width = min(rect.width(), rect.height()) / 1.8 - rect = QtCore.QRectF( - rect.center().x() - (width / 2), - rect.center().y() - (width / 2), - width, width - ) - - # draw port lines. - pen_color = QtGui.QColor(*self.border_color) - pen_color.setAlpha(120) - pen = QtGui.QPen(pen_color, 1.5) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - for p in self.inputs: - if p.isVisible(): - pt1 = QtCore.QPointF( - p.pos().x() + (p.boundingRect().width() / 2), - p.pos().y() + (p.boundingRect().height() / 2) - ) - path = QtGui.QPainterPath() - path.moveTo(pt1) - path.moveTo(QtCore.QPointF(pt1.x(), pt1.y())) - path.lineTo(rect.center()) - painter.drawPath(path) - - for p in self.outputs: - if p.isVisible(): - pt1 = QtCore.QPointF( - p.pos().x() + (p.boundingRect().width() / 2), - p.pos().y() + (p.boundingRect().height() / 2) - ) - path = QtGui.QPainterPath() - path.moveTo(pt1) - path.lineTo(QtCore.QPointF(pt1.x(), pt1.y())) - path.lineTo(rect.center()) - painter.drawPath(path) - - # draw the base color. - painter.setBrush(QtGui.QColor(*self.color)) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.drawEllipse(rect) - - # draw outline. - if self.selected: - # light overlay on background when selected. - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - painter.drawEllipse(rect) - - border_width = 1.2 - border_color = QtGui.QColor( - *NodeEnum.SELECTED_BORDER_COLOR.value - ) - else: - border_width = 0.8 - border_color = QtGui.QColor(*self.border_color) - - # draw the outlines. - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtGui.QPen(border_color, border_width)) - painter.drawEllipse(rect) - - painter.restore() - - def _align_icon_horizontal(self, h_offset, v_offset): - icon_rect = self._icon_item.boundingRect() - x = self.boundingRect().center().x() - (icon_rect.width() / 2) - y = self.boundingRect().top() - self._icon_item.setPos(x + h_offset, y + v_offset) - - def _align_icon_vertical(self, h_offset, v_offset): - rect = self.boundingRect() - icon_rect = self._icon_item.boundingRect() - x = rect.left() - icon_rect.width() + (rect.width() / 4) - y = rect.center().y() - (icon_rect.height() / 2) - self._icon_item.setPos(x + h_offset, y + v_offset) - - def _align_widgets_horizontal(self, v_offset): - if not self._widgets: - return - rect = self.boundingRect() - y = rect.bottom() + v_offset - inputs = [p for p in self.inputs if p.isVisible()] - outputs = [p for p in self.outputs if p.isVisible()] - for widget in self._widgets.values(): - widget_rect = widget.boundingRect() - if not inputs: - x = rect.left() + 10 - widget.widget().setTitleAlign('left') - elif not outputs: - x = rect.right() - widget_rect.width() - 10 - widget.widget().setTitleAlign('right') - else: - x = rect.center().x() - (widget_rect.width() / 2) - widget.widget().setTitleAlign('center') - widget.setPos(x, y) - y += widget_rect.height() - - def _align_widgets_vertical(self, v_offset): - if not self._widgets: - return - rect = self.boundingRect() - y = rect.center().y() + v_offset - widget_height = 0.0 - for widget in self._widgets.values(): - widget_rect = widget.boundingRect() - widget_height += widget_rect.height() - y -= widget_height / 2 - - for widget in self._widgets.values(): - widget_rect = widget.boundingRect() - x = rect.center().x() - (widget_rect.width() / 2) - widget.widget().setTitleAlign('center') - widget.setPos(x, y) - y += widget_rect.height() - - def _align_label_horizontal(self, h_offset, v_offset): - rect = self.boundingRect() - text_rect = self._text_item.boundingRect() - x = rect.center().x() - (text_rect.width() / 2) - y = rect.center().y() - (text_rect.height() / 2) - self._text_item.setPos(x + h_offset, y + v_offset) - - def _align_label_vertical(self, h_offset, v_offset): - rect = self.boundingRect() - text_rect = self._text_item.boundingRect() - x = rect.right() - (rect.width() / 4) - y = rect.center().y() - (text_rect.height() / 2) - self._text_item.setPos(x + h_offset, y + v_offset) - - def _draw_node_horizontal(self): - # update port text items in visibility. - text_width = 0 - port_widths = 0 - for port, text in self._input_items.items(): - text.setVisible(port.display_name) - if port.display_name: - if text.boundingRect().width() > text_width: - text_width = text.boundingRect().width() - port_widths += port.boundingRect().width() / len(self._input_items) - - for port, text in self._output_items.items(): - text.setVisible(port.display_name) - if port.display_name: - if text.boundingRect().width() > text_width: - text_width = text.boundingRect().width() - port_widths += port.boundingRect().width() / len(self._output_items) - - add_width = (text_width * 2) + port_widths - add_height = self.text_item.boundingRect().width() / 2 - - # setup initial base size. - self._set_base_size(add_w=add_width, add_h=add_height) - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- set the initial node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label() - # arrange icon - self.align_icon() - # arrange input and output ports. - self.align_ports() - # arrange node widgets - self.align_widgets(v_offset=0.0) - - self.update() - - def _draw_node_vertical(self): - add_height = 0 - - # hide the port text items in vertical layout. - for port, text in self._input_items.items(): - text.setVisible(False) - add_height += port.boundingRect().height() / 2 - for port, text in self._output_items.items(): - text.setVisible(False) - add_height += port.boundingRect().height() / 2 - - if add_height < 50: - add_height = 50 - - # setup initial base size. - self._set_base_size(add_w=50, add_h=add_height) - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- set the initial node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label() - # align icon - self.align_icon() - # arrange input and output ports. - self.align_ports() - # arrange node widgets - self.align_widgets(v_offset=0.0) - - self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_group.py b/cuegui/NodeGraphQt/qgraphics/node_group.py deleted file mode 100644 index 19d1b8b1c..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_group.py +++ /dev/null @@ -1,317 +0,0 @@ -#!/usr/bin/python -from qtpy import QtCore, QtGui, QtWidgets - -from NodeGraphQt.constants import NodeEnum, PortEnum -from NodeGraphQt.qgraphics.node_base import NodeItem - - -class GroupNodeItem(NodeItem): - """ - Group Node item. - - Args: - name (str): name displayed on the node. - parent (QtWidgets.QGraphicsItem): parent item. - """ - - def __init__(self, name='group', parent=None): - super(GroupNodeItem, self).__init__(name, parent) - - def _paint_horizontal(self, painter, option, widget): - painter.save() - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - - # base background. - margin = 6.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - # draw the base color - offset = 3.0 - rect_1 = QtCore.QRectF(rect.x() + (offset / 2), - rect.y() + offset + 2.0, - rect.width(), rect.height()) - rect_2 = QtCore.QRectF(rect.x() - offset, - rect.y() - offset, - rect.width(), rect.height()) - poly = QtGui.QPolygonF() - poly.append(rect_1.topRight()) - poly.append(rect_2.topRight()) - poly.append(rect_2.bottomLeft()) - poly.append(rect_1.bottomLeft()) - - painter.setBrush(QtGui.QColor(*self.color).darker(180)) - painter.drawRect(rect_1) - painter.drawPolygon(poly) - - painter.setBrush(QtGui.QColor(*self.color)) - painter.drawRect(rect_2) - - if self.selected: - border_color = QtGui.QColor( - *NodeEnum.SELECTED_BORDER_COLOR.value - ) - # light overlay on background when selected. - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - painter.drawRect(rect_2) - else: - border_color = QtGui.QColor(*self.border_color) - - # node name background - padding = 2.0, 2.0 - text_rect = self._text_item.boundingRect() - text_rect = QtCore.QRectF(rect_2.left() + padding[0], - rect_2.top() + padding[1], - rect.right() - (padding[0] * 2) - margin, - text_rect.height() - (padding[1] * 2)) - if self.selected: - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - painter.setBrush(QtGui.QColor(0, 0, 0, 80)) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.drawRect(text_rect) - - # draw the outlines. - pen = QtGui.QPen(border_color.darker(120), 0.8) - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.RoundJoin) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(pen) - painter.drawLines([rect_1.topRight(), rect_2.topRight(), - rect_1.topRight(), rect_1.bottomRight(), - rect_1.bottomRight(), rect_1.bottomLeft(), - rect_1.bottomLeft(), rect_2.bottomLeft()]) - painter.drawLine(rect_1.bottomRight(), rect_2.bottomRight()) - - pen = QtGui.QPen(border_color, 0.8) - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.drawRect(rect_2) - - painter.restore() - - def _paint_vertical(self, painter, option, widget): - painter.save() - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - - # base background. - margin = 6.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - # draw the base color - offset = 3.0 - rect_1 = QtCore.QRectF(rect.x() + offset, - rect.y() + (offset / 2), - rect.width(), rect.height()) - rect_2 = QtCore.QRectF(rect.x() - offset, - rect.y() - offset, - rect.width(), rect.height()) - poly = QtGui.QPolygonF() - poly.append(rect_1.topRight()) - poly.append(rect_2.topRight()) - poly.append(rect_2.bottomLeft()) - poly.append(rect_1.bottomLeft()) - - painter.setBrush(QtGui.QColor(*self.color).darker(180)) - painter.drawRect(rect_1) - painter.drawPolygon(poly) - painter.setBrush(QtGui.QColor(*self.color)) - painter.drawRect(rect_2) - - if self.selected: - border_color = QtGui.QColor( - *NodeEnum.SELECTED_BORDER_COLOR.value - ) - # light overlay on background when selected. - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - painter.drawRect(rect_2) - else: - border_color = QtGui.QColor(*self.border_color) - - # top & bottom edge background. - padding = 2.0 - height = 10 - if self.selected: - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - painter.setBrush(QtGui.QColor(0, 0, 0, 80)) - - painter.setPen(QtCore.Qt.PenStyle.NoPen) - for y in [rect_2.top() + padding, rect_2.bottom() - height - padding]: - top_rect = QtCore.QRectF(rect.x() + padding - offset, y, - rect.width() - (padding * 2), height) - painter.drawRect(top_rect) - - # draw the outlines. - pen = QtGui.QPen(border_color.darker(120), 0.8) - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(pen) - painter.drawLines([rect_1.topRight(), rect_2.topRight(), - rect_1.topRight(), rect_1.bottomRight(), - rect_1.bottomRight(), rect_1.bottomLeft(), - rect_1.bottomLeft(), rect_2.bottomLeft()]) - painter.drawLine(rect_1.bottomRight(), rect_2.bottomRight()) - - pen = QtGui.QPen(border_color, 0.8) - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.drawRect(rect_2) - - painter.restore() - - def _align_icon_horizontal(self, h_offset, v_offset): - super(GroupNodeItem, self)._align_icon_horizontal(h_offset, v_offset) - - def _align_icon_vertical(self, h_offset, v_offset): - y = self._height / 2 - y -= self._icon_item.boundingRect().height() - self._icon_item.setPos(self._width + h_offset, y + v_offset) - - def _align_label_horizontal(self, h_offset, v_offset): - super(GroupNodeItem, self)._align_label_horizontal(h_offset, v_offset) - - def _align_label_vertical(self, h_offset, v_offset): - y = self._height / 2 - y -= self.text_item.boundingRect().height() / 2 - self._text_item.setPos(self._width + h_offset, y + v_offset) - - def _align_ports_horizontal(self, v_offset): - width = self._width - txt_offset = PortEnum.CLICK_FALLOFF.value - 2 - spacing = 1 - - # adjust input position - inputs = [p for p in self.inputs if p.isVisible()] - if inputs: - port_width = inputs[0].boundingRect().width() - port_height = inputs[0].boundingRect().height() - port_x = port_width / 2 * -1 - port_x += 3.0 - port_y = v_offset - for port in inputs: - port.setPos(port_x, port_y) - port_y += port_height + spacing - # adjust input text position - for port, text in self._input_items.items(): - if port.isVisible(): - txt_x = port.boundingRect().width() / 2 - txt_offset - txt_x += 3.0 - text.setPos(txt_x, port.y() - 1.5) - - # adjust output position - outputs = [p for p in self.outputs if p.isVisible()] - if outputs: - port_width = outputs[0].boundingRect().width() - port_height = outputs[0].boundingRect().height() - port_x = width - (port_width / 2) - port_x -= 9.0 - port_y = v_offset - for port in outputs: - port.setPos(port_x, port_y) - port_y += port_height + spacing - # adjust output text position - for port, text in self._output_items.items(): - if port.isVisible(): - txt_width = text.boundingRect().width() - txt_offset - txt_x = port.x() - txt_width - text.setPos(txt_x, port.y() - 1.5) - - def _align_ports_vertical(self, v_offset): - # adjust input position - inputs = [p for p in self.inputs if p.isVisible()] - if inputs: - port_width = inputs[0].boundingRect().width() - port_height = inputs[0].boundingRect().height() - half_width = port_width / 2 - delta = self._width / (len(inputs) + 1) - port_x = delta - port_y = -port_height / 2 + 3.0 - for port in inputs: - port.setPos(port_x - half_width, port_y) - port_x += delta - - # adjust output position - outputs = [p for p in self.outputs if p.isVisible()] - if outputs: - port_width = outputs[0].boundingRect().width() - port_height = outputs[0].boundingRect().height() - half_width = port_width / 2 - delta = self._width / (len(outputs) + 1) - port_x = delta - port_y = self._height - (port_height / 2) - 9.0 - for port in outputs: - port.setPos(port_x - half_width, port_y) - port_x += delta - - def _draw_node_horizontal(self): - height = self._text_item.boundingRect().height() - - # update port text items in visibility. - for port, text in self._input_items.items(): - text.setVisible(port.display_name) - for port, text in self._output_items.items(): - text.setVisible(port.display_name) - - # setup initial base size. - self._set_base_size(add_w=8.0, add_h=height + 10) - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- set the initial node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label() - # arrange icon - self.align_icon(h_offset=2.0, v_offset=3.0) - # arrange input and output ports. - self.align_ports(v_offset=height) - # arrange node widgets - self.align_widgets(v_offset=height) - - self.update() - - def _draw_node_vertical(self): - height = self._text_item.boundingRect().height() - - # hide the port text items in vertical layout. - for port, text in self._input_items.items(): - text.setVisible(False) - for port, text in self._output_items.items(): - text.setVisible(False) - - # setup initial base size. - self._set_base_size(add_w=8.0) - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- set the initial node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label(h_offset=7, v_offset=6) - # align icon - self.align_icon(h_offset=4, v_offset=-2) - # arrange input and output ports. - self.align_ports(v_offset=height + (height / 2)) - # arrange node widgets - self.align_widgets(v_offset=height / 2) - - self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py b/cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py deleted file mode 100644 index 9d8401145..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_overlay_disabled.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/python -from qtpy import QtGui, QtCore, QtWidgets - -from NodeGraphQt.constants import Z_VAL_NODE_WIDGET - - -class XDisabledItem(QtWidgets.QGraphicsItem): - """ - Node disabled overlay item. - - Args: - parent (NodeItem): the parent node item. - text (str): disable overlay text. - """ - - def __init__(self, parent=None, text=None): - super(XDisabledItem, self).__init__(parent) - self.setZValue(Z_VAL_NODE_WIDGET + 2) - self.setVisible(False) - self.proxy_mode = False - self.color = (0, 0, 0, 255) - self.text = text - - def boundingRect(self): - return self.parentItem().boundingRect() - - def paint(self, painter, option, widget): - """ - Draws the overlay disabled X item on top of a node item. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - painter.save() - - margin = 20 - rect = self.boundingRect() - dis_rect = QtCore.QRectF(rect.left() - (margin / 2), - rect.top() - (margin / 2), - rect.width() + margin, - rect.height() + margin) - if not self.proxy_mode: - pen = QtGui.QPen(QtGui.QColor(*self.color), 8) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - painter.drawLine(dis_rect.topLeft(), dis_rect.bottomRight()) - painter.drawLine(dis_rect.topRight(), dis_rect.bottomLeft()) - - bg_color = QtGui.QColor(*self.color) - bg_color.setAlpha(100) - bg_margin = -0.5 - bg_rect = QtCore.QRectF(dis_rect.left() - (bg_margin / 2), - dis_rect.top() - (bg_margin / 2), - dis_rect.width() + bg_margin, - dis_rect.height() + bg_margin) - painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0, 0))) - painter.setBrush(bg_color) - painter.drawRoundedRect(bg_rect, 5, 5) - - if not self.proxy_mode: - point_size = 4.0 - pen = QtGui.QPen(QtGui.QColor(155, 0, 0, 255), 0.7) - else: - point_size = 8.0 - pen = QtGui.QPen(QtGui.QColor(155, 0, 0, 255), 4.0) - - painter.setPen(pen) - painter.drawLine(dis_rect.topLeft(), dis_rect.bottomRight()) - painter.drawLine(dis_rect.topRight(), dis_rect.bottomLeft()) - - point_pos = (dis_rect.topLeft(), dis_rect.topRight(), - dis_rect.bottomLeft(), dis_rect.bottomRight()) - painter.setBrush(QtGui.QColor(255, 0, 0, 255)) - for p in point_pos: - p.setX(p.x() - (point_size / 2)) - p.setY(p.y() - (point_size / 2)) - point_rect = QtCore.QRectF( - p, QtCore.QSizeF(point_size, point_size)) - painter.drawEllipse(point_rect) - - if self.text and not self.proxy_mode: - font = painter.font() - font.setPointSize(10) - - painter.setFont(font) - font_metrics = QtGui.QFontMetrics(font) - font_width = font_metrics.width(self.text) - font_height = font_metrics.height() - txt_w = font_width * 1.25 - txt_h = font_height * 2.25 - text_bg_rect = QtCore.QRectF((rect.width() / 2) - (txt_w / 2), - (rect.height() / 2) - (txt_h / 2), - txt_w, txt_h) - painter.setPen(QtGui.QPen(QtGui.QColor(255, 0, 0), 0.5)) - painter.setBrush(QtGui.QColor(*self.color)) - painter.drawRoundedRect(text_bg_rect, 2, 2) - - text_rect = QtCore.QRectF((rect.width() / 2) - (font_width / 2), - (rect.height() / 2) - (font_height / 2), - txt_w * 2, font_height * 2) - - painter.setPen(QtGui.QPen(QtGui.QColor(255, 0, 0), 1)) - painter.drawText(text_rect, self.text) - - painter.restore() diff --git a/cuegui/NodeGraphQt/qgraphics/node_port_in.py b/cuegui/NodeGraphQt/qgraphics/node_port_in.py deleted file mode 100644 index aa5981cb3..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_port_in.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/python -from qtpy import QtCore, QtGui, QtWidgets - -from NodeGraphQt.constants import NodeEnum -from NodeGraphQt.qgraphics.node_base import NodeItem - - -class PortInputNodeItem(NodeItem): - """ - Input Port Node item. - - Args: - name (str): name displayed on the node. - parent (QtWidgets.QGraphicsItem): parent item. - """ - - def __init__(self, name='group port', parent=None): - super(PortInputNodeItem, self).__init__(name, parent) - self._icon_item.setVisible(False) - self._text_item.set_locked(True) - self._x_item.text = 'Port Locked' - - def _set_base_size(self, add_w=0.0, add_h=0.0): - width, height = self.calc_size(add_w, add_h) - self._width = width + 60 - self._height = height if height >= 60 else 60 - - def _paint_horizontal(self, painter, option, widget): - self.auto_switch_mode() - - painter.save() - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - - margin = 2.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - text_rect = self._text_item.boundingRect() - text_rect = QtCore.QRectF( - rect.center().x() - (text_rect.width() / 2) - 5, - rect.center().y() - (text_rect.height() / 2), - text_rect.width() + 10, - text_rect.height() - ) - - painter.setBrush(QtGui.QColor(255, 255, 255, 20)) - painter.drawRoundedRect(rect, 20, 20) - - painter.setBrush(QtGui.QColor(0, 0, 0, 100)) - painter.drawRoundedRect(text_rect, 3, 3) - - size = int(rect.height() / 4) - triangle = QtGui.QPolygonF() - triangle.append(QtCore.QPointF(-size, size)) - triangle.append(QtCore.QPointF(0.0, 0.0)) - triangle.append(QtCore.QPointF(size, size)) - - transform = QtGui.QTransform() - transform.translate(rect.width() - (size / 6), rect.center().y()) - transform.rotate(90) - poly = transform.map(triangle) - - if self.selected: - pen = QtGui.QPen( - QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 - ) - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) - painter.setBrush(QtGui.QColor(0, 0, 0, 50)) - - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - painter.setPen(pen) - painter.drawPolygon(poly) - - edge_size = 30 - edge_rect = QtCore.QRectF(rect.width() - (size * 1.7), - rect.center().y() - (edge_size / 2), - 4, edge_size) - painter.drawRect(edge_rect) - - painter.restore() - - def _paint_vertical(self, painter, option, widget): - self.auto_switch_mode() - - painter.save() - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - - margin = 2.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - text_rect = self._text_item.boundingRect() - text_rect = QtCore.QRectF( - rect.center().x() - (text_rect.width() / 2) - 5, - rect.top() + margin, - text_rect.width() + 10, - text_rect.height() - ) - - painter.setBrush(QtGui.QColor(255, 255, 255, 20)) - painter.drawRoundedRect(rect, 20, 20) - - painter.setBrush(QtGui.QColor(0, 0, 0, 100)) - painter.drawRoundedRect(text_rect, 3, 3) - - size = int(rect.height() / 4) - triangle = QtGui.QPolygonF() - triangle.append(QtCore.QPointF(-size, size)) - triangle.append(QtCore.QPointF(0.0, 0.0)) - triangle.append(QtCore.QPointF(size, size)) - - transform = QtGui.QTransform() - transform.translate(rect.center().x(), rect.bottom() - (size / 3)) - transform.rotate(180) - poly = transform.map(triangle) - - if self.selected: - pen = QtGui.QPen( - QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 - ) - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) - painter.setBrush(QtGui.QColor(0, 0, 0, 50)) - - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - painter.setPen(pen) - painter.drawPolygon(poly) - - edge_size = 30 - edge_rect = QtCore.QRectF(rect.center().x() - (edge_size / 2), - rect.bottom() - (size * 1.9), - edge_size, 4) - painter.drawRect(edge_rect) - - painter.restore() - - def set_proxy_mode(self, mode): - """ - Set whether to draw the node with proxy mode. - (proxy mode toggles visibility for some qgraphic items in the node.) - - Args: - mode (bool): true to enable proxy mode. - """ - if mode is self._proxy_mode: - return - self._proxy_mode = mode - - visible = not mode - - # disable overlay item. - self._x_item.proxy_mode = self._proxy_mode - - # node widget visibility. - for w in self._widgets.values(): - w.widget().setVisible(visible) - - # input port text visibility. - for port, text in self._input_items.items(): - if port.display_name: - text.setVisible(visible) - - # output port text visibility. - for port, text in self._output_items.items(): - if port.display_name: - text.setVisible(visible) - - self._text_item.setVisible(visible) - - def _align_label_horizontal(self, h_offset, v_offset): - rect = self.boundingRect() - text_rect = self._text_item.boundingRect() - x = rect.center().x() - (text_rect.width() / 2) - y = rect.center().y() - (text_rect.height() / 2) - self._text_item.setPos(x + h_offset, y + v_offset) - - def _align_label_vertical(self, h_offset, v_offset): - rect = self.boundingRect() - text_rect = self._text_item.boundingRect() - x = rect.center().x() - (text_rect.width() / 1.5) - 2.0 - y = rect.center().y() - text_rect.height() - 2.0 - self._text_item.setPos(x + h_offset, y + v_offset) - - def _align_ports_horizontal(self, v_offset): - """ - Align input, output ports in the node layout. - """ - v_offset = self.boundingRect().height() / 2 - if self.inputs or self.outputs: - for ports in [self.inputs, self.outputs]: - if ports: - v_offset -= ports[0].boundingRect().height() / 2 - break - super(PortInputNodeItem, self)._align_ports_horizontal(v_offset) - - def _align_ports_vertical(self, v_offset): - super(PortInputNodeItem, self)._align_ports_vertical(v_offset) - - def _draw_node_horizontal(self): - """ - Re-draw the node item in the scene. - (re-implemented for vertical layout design) - """ - # setup initial base size. - self._set_base_size() - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- set the initial node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label() - # arrange icon - self.align_icon() - # arrange input and output ports. - self.align_ports() - # arrange node widgets - self.align_widgets() - - self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_port_out.py b/cuegui/NodeGraphQt/qgraphics/node_port_out.py deleted file mode 100644 index cce6d89d8..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_port_out.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/python -from qtpy import QtCore, QtGui, QtWidgets - -from NodeGraphQt.constants import NodeEnum -from NodeGraphQt.qgraphics.node_base import NodeItem - - -class PortOutputNodeItem(NodeItem): - """ - Output Port Node item. - - Args: - name (str): name displayed on the node. - parent (QtWidgets.QGraphicsItem): parent item. - """ - - def __init__(self, name='group port', parent=None): - super(PortOutputNodeItem, self).__init__(name, parent) - self._icon_item.setVisible(False) - self._text_item.set_locked(True) - self._x_item.text = 'Port Locked' - - def _set_base_size(self, add_w=0.0, add_h=0.0): - width, height = self.calc_size(add_w, add_h) - self._width = width + 60 - self._height = height if height >= 60 else 60 - - def _paint_horizontal(self, painter, option, widget): - self.auto_switch_mode() - - painter.save() - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - - margin = 2.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - text_rect = self._text_item.boundingRect() - text_rect = QtCore.QRectF( - rect.center().x() - (text_rect.width() / 2) - 5, - rect.center().y() - (text_rect.height() / 2), - text_rect.width() + 10, - text_rect.height() - ) - - painter.setBrush(QtGui.QColor(255, 255, 255, 20)) - painter.drawRoundedRect(rect, 20, 20) - - painter.setBrush(QtGui.QColor(0, 0, 0, 100)) - painter.drawRoundedRect(text_rect, 3, 3) - - size = int(rect.height() / 4) - triangle = QtGui.QPolygonF() - triangle.append(QtCore.QPointF(-size, size)) - triangle.append(QtCore.QPointF(0.0, 0.0)) - triangle.append(QtCore.QPointF(size, size)) - - transform = QtGui.QTransform() - transform.translate(rect.x() + (size / 3), rect.center().y()) - transform.rotate(-90) - poly = transform.map(triangle) - - if self.selected: - pen = QtGui.QPen( - QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 - ) - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) - painter.setBrush(QtGui.QColor(0, 0, 0, 50)) - - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - painter.setPen(pen) - painter.drawPolygon(poly) - - edge_size = 30 - edge_rect = QtCore.QRectF(rect.x() + (size * 1.6), - rect.center().y() - (edge_size / 2), - 4, edge_size) - painter.drawRect(edge_rect) - - painter.restore() - - def _paint_vertical(self, painter, option, widget): - self.auto_switch_mode() - - painter.save() - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(QtCore.Qt.PenStyle.NoPen) - - margin = 2.0 - rect = self.boundingRect() - rect = QtCore.QRectF(rect.left() + margin, - rect.top() + margin, - rect.width() - (margin * 2), - rect.height() - (margin * 2)) - - text_rect = self._text_item.boundingRect() - text_rect = QtCore.QRectF( - rect.center().x() - (text_rect.width() / 2) - 5, - rect.height() - text_rect.height(), - text_rect.width() + 10, - text_rect.height() - ) - - painter.setBrush(QtGui.QColor(255, 255, 255, 20)) - painter.drawRoundedRect(rect, 20, 20) - - painter.setBrush(QtGui.QColor(0, 0, 0, 100)) - painter.drawRoundedRect(text_rect, 3, 3) - - size = int(rect.height() / 4) - triangle = QtGui.QPolygonF() - triangle.append(QtCore.QPointF(-size, size)) - triangle.append(QtCore.QPointF(0.0, 0.0)) - triangle.append(QtCore.QPointF(size, size)) - - transform = QtGui.QTransform() - transform.translate(rect.center().x(), rect.y() + (size / 3)) - # transform.rotate(-90) - poly = transform.map(triangle) - - if self.selected: - pen = QtGui.QPen( - QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value), 1.3 - ) - painter.setBrush(QtGui.QColor(*NodeEnum.SELECTED_COLOR.value)) - else: - pen = QtGui.QPen(QtGui.QColor(*self.border_color), 1.2) - painter.setBrush(QtGui.QColor(0, 0, 0, 50)) - - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - painter.setPen(pen) - painter.drawPolygon(poly) - - edge_size = 30 - edge_rect = QtCore.QRectF(rect.center().x() - (edge_size / 2), - rect.y() + (size * 1.6), - edge_size, 4) - painter.drawRect(edge_rect) - - painter.restore() - - def set_proxy_mode(self, mode): - """ - Set whether to draw the node with proxy mode. - (proxy mode toggles visibility for some qgraphic items in the node.) - - Args: - mode (bool): true to enable proxy mode. - """ - if mode is self._proxy_mode: - return - self._proxy_mode = mode - - visible = not mode - - # disable overlay item. - self._x_item.proxy_mode = self._proxy_mode - - # node widget visibility. - for w in self._widgets.values(): - w.widget().setVisible(visible) - - # input port text visibility. - for port, text in self._input_items.items(): - if port.display_name: - text.setVisible(visible) - - # output port text visibility. - for port, text in self._output_items.items(): - if port.display_name: - text.setVisible(visible) - - self._text_item.setVisible(visible) - - def _align_label_horizontal(self, h_offset, v_offset): - rect = self.boundingRect() - text_rect = self._text_item.boundingRect() - x = rect.center().x() - (text_rect.width() / 2) - y = rect.center().y() - (text_rect.height() / 2) - self._text_item.setPos(x + h_offset, y + v_offset) - - def _align_label_vertical(self, h_offset, v_offset): - rect = self.boundingRect() - text_rect = self._text_item.boundingRect() - x = rect.center().x() - (text_rect.width() / 1.5) - 2.0 - y = rect.height() - text_rect.height() - 4.0 - self._text_item.setPos(x + h_offset, y + v_offset) - - def _align_ports_horizontal(self, v_offset): - """ - Align input, output ports in the node layout. - """ - v_offset = self.boundingRect().height() / 2 - if self.inputs or self.outputs: - for ports in [self.inputs, self.outputs]: - if ports: - v_offset -= ports[0].boundingRect().height() / 2 - break - super(PortOutputNodeItem, self)._align_ports_horizontal(v_offset) - - def _align_ports_vertical(self, v_offset): - super(PortOutputNodeItem, self)._align_ports_vertical(v_offset) - - def _draw_node_horizontal(self): - """ - Re-draw the node item in the scene. - (re-implemented for vertical layout design) - """ - # setup initial base size. - self._set_base_size() - # set text color when node is initialized. - self._set_text_color(self.text_color) - # set the tooltip - self._tooltip_disable(self.disabled) - - # --- set the initial node layout --- - # (do all the graphic item layout offsets here) - - # align label text - self.align_label() - # align icon - self.align_icon() - # arrange input and output ports. - self.align_ports() - # arrange node widgets - self.align_widgets() - - self.update() diff --git a/cuegui/NodeGraphQt/qgraphics/node_text_item.py b/cuegui/NodeGraphQt/qgraphics/node_text_item.py deleted file mode 100644 index e8840dc1f..000000000 --- a/cuegui/NodeGraphQt/qgraphics/node_text_item.py +++ /dev/null @@ -1,117 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui - - -class NodeTextItem(QtWidgets.QGraphicsTextItem): - """ - NodeTextItem class used to display and edit the name of a NodeItem. - """ - - def __init__(self, text, parent=None): - super(NodeTextItem, self).__init__(text, parent) - self._locked = False - self.set_locked(False) - self.set_editable(False) - - def mouseDoubleClickEvent(self, event): - """ - Re-implemented to jump into edit mode when user clicks on node text. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): mouse event. - """ - if not self._locked: - if event.button() == QtCore.Qt.MouseButton.LeftButton: - self.set_editable(True) - event.ignore() - return - super(NodeTextItem, self).mouseDoubleClickEvent(event) - - def keyPressEvent(self, event): - """ - Re-implemented to catch the Return & Escape keys when in edit mode. - - Args: - event (QtGui.QKeyEvent): key event. - """ - if event.key() == QtCore.Qt.Key.Key_Return: - current_text = self.toPlainText() - self.set_node_name(current_text) - self.set_editable(False) - elif event.key() == QtCore.Qt.Key.Key_Escape: - self.setPlainText(self.node.name) - self.set_editable(False) - super(NodeTextItem, self).keyPressEvent(event) - - def focusOutEvent(self, event): - """ - Re-implemented to jump out of edit mode. - - Args: - event (QtGui.QFocusEvent): - """ - current_text = self.toPlainText() - self.set_node_name(current_text) - self.set_editable(False) - super(NodeTextItem, self).focusOutEvent(event) - - def set_editable(self, value=False): - """ - Set the edit mode for the text item. - - Args: - value (bool): true in edit mode. - """ - if self._locked: - return - if value: - self.setTextInteractionFlags( - QtCore.Qt.TextInteractionFlag.TextEditable | - QtCore.Qt.TextInteractionFlag.TextSelectableByMouse | - QtCore.Qt.TextInteractionFlag.TextSelectableByKeyboard - ) - else: - self.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.NoTextInteraction) - cursor = self.textCursor() - cursor.clearSelection() - self.setTextCursor(cursor) - - def set_node_name(self, name): - """ - Updates the node name through the node "NodeViewer().node_name_changed" - signal which then updates the node name through the BaseNode object this - will register it as an undo command. - - Args: - name (str): new node name. - """ - name = name.strip() - if name != self.node.name: - viewer = self.node.viewer() - viewer.node_name_changed.emit(self.node.id, name) - - def set_locked(self, state=False): - """ - Locks the text item so it can not be editable. - - Args: - state (bool): lock state. - """ - self._locked = state - if self._locked: - self.setFlag(QtWidgets.QGraphicsItem.ItemIsFocusable, False) - self.setCursor(QtCore.Qt.CursorShape.ArrowCursor) - self.setToolTip('') - else: - self.setFlag(QtWidgets.QGraphicsItem.ItemIsFocusable, True) - self.setToolTip('double-click to edit node name.') - self.setCursor(QtCore.Qt.CursorShape.IBeamCursor) - - @property - def node(self): - """ - Get the parent node item. - - Returns: - NodeItem: parent node qgraphics item. - """ - return self.parentItem() diff --git a/cuegui/NodeGraphQt/qgraphics/pipe.py b/cuegui/NodeGraphQt/qgraphics/pipe.py deleted file mode 100644 index 85d8aa1e1..000000000 --- a/cuegui/NodeGraphQt/qgraphics/pipe.py +++ /dev/null @@ -1,666 +0,0 @@ -#!/usr/bin/python -import math - -from qtpy import QtCore, QtGui, QtWidgets - -from NodeGraphQt.constants import ( - LayoutDirectionEnum, - PipeEnum, - PipeLayoutEnum, - PortTypeEnum, - ITEM_CACHE_MODE, - Z_VAL_PIPE, - Z_VAL_NODE_WIDGET -) -from NodeGraphQt.qgraphics.port import PortItem - -PIPE_STYLES = { - PipeEnum.DRAW_TYPE_DEFAULT.value: QtCore.Qt.PenStyle.SolidLine, - PipeEnum.DRAW_TYPE_DASHED.value: QtCore.Qt.PenStyle.DashLine, - PipeEnum.DRAW_TYPE_DOTTED.value: QtCore.Qt.PenStyle.DotLine -} - - -class PipeItem(QtWidgets.QGraphicsPathItem): - """ - Base Pipe item used for drawing node connections. - """ - - def __init__(self, input_port=None, output_port=None): - super(PipeItem, self).__init__() - self.setZValue(Z_VAL_PIPE) - self.setAcceptHoverEvents(True) - self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable) - self.setCacheMode(ITEM_CACHE_MODE) - - self._color = PipeEnum.COLOR.value - self._style = PipeEnum.DRAW_TYPE_DEFAULT.value - self._active = False - self._highlight = False - self._input_port = input_port - self._output_port = output_port - - size = 6.0 - self._poly = QtGui.QPolygonF() - self._poly.append(QtCore.QPointF(-size, size)) - self._poly.append(QtCore.QPointF(0.0, -size * 1.5)) - self._poly.append(QtCore.QPointF(size, size)) - - self._dir_pointer = QtWidgets.QGraphicsPolygonItem(self) - self._dir_pointer.setPolygon(self._poly) - self._dir_pointer.setFlag(self.GraphicsItemFlag.ItemIsSelectable, False) - - self.reset() - - def __repr__(self): - in_name = self._input_port.name if self._input_port else '' - out_name = self._output_port.name if self._output_port else '' - return '{}.Pipe(\'{}\', \'{}\')'.format( - self.__module__, in_name, out_name) - - def hoverEnterEvent(self, event): - self.activate() - - def hoverLeaveEvent(self, event): - self.reset() - if self.input_port and self.output_port: - if self.input_port.node.selected: - self.highlight() - elif self.output_port.node.selected: - self.highlight() - if self.isSelected(): - self.highlight() - - def itemChange(self, change, value): - if change == self.GraphicsItemChange.ItemSelectedChange and self.scene(): - if value: - self.highlight() - else: - self.reset() - return super(PipeItem, self).itemChange(change, value) - - def paint(self, painter, option, widget): - """ - Draws the connection line between nodes. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - painter.save() - - pen = self.pen() - if self.disabled(): - if not self._active: - pen.setColor(QtGui.QColor(*PipeEnum.DISABLED_COLOR.value)) - pen.setStyle(PIPE_STYLES.get(PipeEnum.DRAW_TYPE_DOTTED.value)) - pen.setWidth(3) - - painter.setPen(pen) - painter.setBrush(self.brush()) - painter.setRenderHint(painter.RenderHint.Antialiasing, True) - painter.drawPath(self.path()) - - # QPaintDevice: Cannot destroy paint device that is being painted. - painter.restore() - - @staticmethod - def _calc_distance(p1, p2): - x = math.pow((p2.x() - p1.x()), 2) - y = math.pow((p2.y() - p1.y()), 2) - return math.sqrt(x + y) - - def _draw_direction_pointer(self): - """ - updates the pipe direction pointer arrow. - """ - if not (self.input_port and self.output_port): - self._dir_pointer.setVisible(False) - return - - if self.disabled(): - if not (self._active or self._highlight): - color = QtGui.QColor(*PipeEnum.DISABLED_COLOR.value) - pen = self._dir_pointer.pen() - pen.setColor(color) - self._dir_pointer.setPen(pen) - self._dir_pointer.setBrush(color.darker(200)) - - self._dir_pointer.setVisible(True) - loc_pt = self.path().pointAtPercent(0.49) - tgt_pt = self.path().pointAtPercent(0.51) - radians = math.atan2(tgt_pt.y() - loc_pt.y(), - tgt_pt.x() - loc_pt.x()) - degrees = math.degrees(radians) - 90 - self._dir_pointer.setRotation(degrees) - self._dir_pointer.setPos(self.path().pointAtPercent(0.5)) - - cen_x = self.path().pointAtPercent(0.5).x() - cen_y = self.path().pointAtPercent(0.5).y() - dist = math.hypot(tgt_pt.x() - cen_x, tgt_pt.y() - cen_y) - - self._dir_pointer.setVisible(True) - if dist < 0.3: - self._dir_pointer.setVisible(False) - return - if dist < 1.0: - self._dir_pointer.setScale(dist) - - def _draw_path_cycled_vertical(self, start_port, pos1, pos2, path): - """ - Draw pipe vertically around node if connection is cyclic. - - Args: - start_port (PortItem): port used to draw the starting point. - pos1 (QPointF): start port position. - pos2 (QPointF): end port position. - path (QPainterPath): path to draw. - """ - n_rect = start_port.node.boundingRect() - ptype = start_port.port_type - start_pos = pos1 if ptype == PortTypeEnum.IN.value else pos2 - end_pos = pos2 if ptype == PortTypeEnum.IN.value else pos1 - - padding = 40 - top = start_pos.y() - padding - bottom = end_pos.y() + padding - path.moveTo(end_pos) - path.lineTo(end_pos.x(), bottom) - path.lineTo(end_pos.x() + n_rect.right(), bottom) - path.lineTo(end_pos.x() + n_rect.right(), top) - path.lineTo(start_pos.x(), top) - path.lineTo(start_pos) - self.setPath(path) - - def _draw_path_cycled_horizontal(self, start_port, pos1, pos2, path): - """ - Draw pipe horizontally around node if connection is cyclic. - - Args: - start_port (PortItem): port used to draw the starting point. - pos1 (QPointF): start port position. - pos2 (QPointF): end port position. - path (QPainterPath): path to draw. - """ - n_rect = start_port.node.boundingRect() - ptype = start_port.port_type - start_pos = pos1 if ptype == PortTypeEnum.IN.value else pos2 - end_pos = pos2 if ptype == PortTypeEnum.IN.value else pos1 - - padding = 40 - left = end_pos.x() + padding - right = start_pos.x() - padding - path.moveTo(start_pos) - path.lineTo(right, start_pos.y()) - path.lineTo(right, end_pos.y() + n_rect.bottom()) - path.lineTo(left, end_pos.y() + n_rect.bottom()) - path.lineTo(left, end_pos.y()) - path.lineTo(end_pos) - self.setPath(path) - - def _draw_path_vertical(self, start_port, pos1, pos2, path): - """ - Draws the vertical path between ports. - - Args: - start_port (PortItem): port used to draw the starting point. - pos1 (QPointF): start port position. - pos2 (QPointF): end port position. - path (QPainterPath): path to draw. - """ - if self.viewer_pipe_layout() == PipeLayoutEnum.CURVED.value: - ctr_offset_y1, ctr_offset_y2 = pos1.y(), pos2.y() - tangent = abs(ctr_offset_y1 - ctr_offset_y2) - - max_height = start_port.node.boundingRect().height() - tangent = min(tangent, max_height) - if start_port.port_type == PortTypeEnum.IN.value: - ctr_offset_y1 -= tangent - ctr_offset_y2 += tangent - else: - ctr_offset_y1 += tangent - ctr_offset_y2 -= tangent - - ctr_point1 = QtCore.QPointF(pos1.x(), ctr_offset_y1) - ctr_point2 = QtCore.QPointF(pos2.x(), ctr_offset_y2) - path.cubicTo(ctr_point1, ctr_point2, pos2) - self.setPath(path) - elif self.viewer_pipe_layout() == PipeLayoutEnum.ANGLE.value: - ctr_offset_y1, ctr_offset_y2 = pos1.y(), pos2.y() - distance = abs(ctr_offset_y1 - ctr_offset_y2)/2 - if start_port.port_type == PortTypeEnum.IN.value: - ctr_offset_y1 -= distance - ctr_offset_y2 += distance - else: - ctr_offset_y1 += distance - ctr_offset_y2 -= distance - - ctr_point1 = QtCore.QPointF(pos1.x(), ctr_offset_y1) - ctr_point2 = QtCore.QPointF(pos2.x(), ctr_offset_y2) - path.lineTo(ctr_point1) - path.lineTo(ctr_point2) - path.lineTo(pos2) - self.setPath(path) - - def _draw_path_horizontal(self, start_port, pos1, pos2, path): - """ - Draws the horizontal path between ports. - - Args: - start_port (PortItem): port used to draw the starting point. - pos1 (QPointF): start port position. - pos2 (QPointF): end port position. - path (QPainterPath): path to draw. - """ - if self.viewer_pipe_layout() == PipeLayoutEnum.CURVED.value: - ctr_offset_x1, ctr_offset_x2 = pos1.x(), pos2.x() - tangent = abs(ctr_offset_x1 - ctr_offset_x2) - - max_width = start_port.node.boundingRect().width() - tangent = min(tangent, max_width) - if start_port.port_type == PortTypeEnum.IN.value: - ctr_offset_x1 -= tangent - ctr_offset_x2 += tangent - else: - ctr_offset_x1 += tangent - ctr_offset_x2 -= tangent - - ctr_point1 = QtCore.QPointF(ctr_offset_x1, pos1.y()) - ctr_point2 = QtCore.QPointF(ctr_offset_x2, pos2.y()) - path.cubicTo(ctr_point1, ctr_point2, pos2) - self.setPath(path) - elif self.viewer_pipe_layout() == PipeLayoutEnum.ANGLE.value: - ctr_offset_x1, ctr_offset_x2 = pos1.x(), pos2.x() - distance = abs(ctr_offset_x1 - ctr_offset_x2) / 2 - if start_port.port_type == PortTypeEnum.IN.value: - ctr_offset_x1 -= distance - ctr_offset_x2 += distance - else: - ctr_offset_x1 += distance - ctr_offset_x2 -= distance - - ctr_point1 = QtCore.QPointF(ctr_offset_x1, pos1.y()) - ctr_point2 = QtCore.QPointF(ctr_offset_x2, pos2.y()) - path.lineTo(ctr_point1) - path.lineTo(ctr_point2) - path.lineTo(pos2) - self.setPath(path) - - def draw_path(self, start_port, end_port=None, cursor_pos=None): - """ - Draws the path between ports. - - Args: - start_port (PortItem): port used to draw the starting point. - end_port (PortItem): port used to draw the end point. - cursor_pos (QtCore.QPointF): cursor position if specified this - will be the draw end point. - """ - if not start_port: - return - - # get start / end positions. - pos1 = start_port.scenePos() - pos1.setX(pos1.x() + (start_port.boundingRect().width() / 2)) - pos1.setY(pos1.y() + (start_port.boundingRect().height() / 2)) - if cursor_pos: - pos2 = cursor_pos - elif end_port: - pos2 = end_port.scenePos() - pos2.setX(pos2.x() + (start_port.boundingRect().width() / 2)) - pos2.setY(pos2.y() + (start_port.boundingRect().height() / 2)) - else: - return - - # visibility check for connected pipe. - if self.input_port and self.output_port: - is_visible = all([ - self._input_port.isVisible(), - self._output_port.isVisible(), - self._input_port.node.isVisible(), - self._output_port.node.isVisible() - ]) - self.setVisible(is_visible) - - # don't draw pipe if a port or node is not visible. - if not is_visible: - return - - line = QtCore.QLineF(pos1, pos2) - path = QtGui.QPainterPath() - - direction = self.viewer_layout_direction() - - if end_port and not self.viewer().acyclic: - if end_port.node == start_port.node: - if direction is LayoutDirectionEnum.VERTICAL.value: - self._draw_path_cycled_vertical( - start_port, pos1, pos2, path - ) - self._draw_direction_pointer() - return - elif direction is LayoutDirectionEnum.HORIZONTAL.value: - self._draw_path_cycled_horizontal( - start_port, pos1, pos2, path - ) - self._draw_direction_pointer() - return - - path.moveTo(line.x1(), line.y1()) - - if self.viewer_pipe_layout() == PipeLayoutEnum.STRAIGHT.value: - path.lineTo(pos2) - self.setPath(path) - self._draw_direction_pointer() - return - - if direction is LayoutDirectionEnum.VERTICAL.value: - self._draw_path_vertical(start_port, pos1, pos2, path) - elif direction is LayoutDirectionEnum.HORIZONTAL.value: - self._draw_path_horizontal(start_port, pos1, pos2, path) - - self._draw_direction_pointer() - - def reset_path(self): - """ - reset the pipe initial path position. - """ - path = QtGui.QPainterPath(QtCore.QPointF(0.0, 0.0)) - self.setPath(path) - self._draw_direction_pointer() - - def port_from_pos(self, pos, reverse=False): - """ - Args: - pos (QtCore.QPointF): current scene position. - reverse (bool): false to return the nearest port. - - Returns: - PortItem: port item. - """ - inport_pos = self.input_port.scenePos() - outport_pos = self.output_port.scenePos() - input_dist = self._calc_distance(inport_pos, pos) - output_dist = self._calc_distance(outport_pos, pos) - if input_dist < output_dist: - port = self.output_port if reverse else self.input_port - else: - port = self.input_port if reverse else self.output_port - return port - - def viewer(self): - """ - Returns: - NodeViewer: node graph viewer. - """ - if self.scene(): - return self.scene().viewer() - - def viewer_pipe_layout(self): - """ - Returns: - int: pipe layout mode. - """ - viewer = self.viewer() - if viewer: - return viewer.get_pipe_layout() - - def viewer_layout_direction(self): - """ - Returns: - int: graph layout mode. - """ - viewer = self.viewer() - if viewer: - return viewer.get_layout_direction() - - def set_pipe_styling(self, color, width=2, style=0): - """ - Args: - color (list or tuple): (r, g, b, a) values 0-255 - width (int): pipe width. - style (int): pipe style. - """ - pen = self.pen() - pen.setWidth(width) - pen.setColor(QtGui.QColor(*color)) - pen.setStyle(PIPE_STYLES.get(style)) - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - self.setPen(pen) - self.setBrush(QtGui.QBrush(QtCore.Qt.NoBrush)) - - pen = self._dir_pointer.pen() - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - pen.setWidth(width) - pen.setColor(QtGui.QColor(*color)) - self._dir_pointer.setPen(pen) - self._dir_pointer.setBrush(QtGui.QColor(*color).darker(200)) - - def activate(self): - self._active = True - self.set_pipe_styling( - color=PipeEnum.ACTIVE_COLOR.value, - width=3, - style=PipeEnum.DRAW_TYPE_DEFAULT.value - ) - - def active(self): - return self._active - - def highlight(self): - self._highlight = True - self.set_pipe_styling( - color=PipeEnum.HIGHLIGHT_COLOR.value, - width=2, - style=PipeEnum.DRAW_TYPE_DEFAULT.value - ) - - def highlighted(self): - return self._highlight - - def reset(self): - """ - reset the pipe state and styling. - """ - self._active = False - self._highlight = False - self.set_pipe_styling(color=self.color, width=2, style=self.style) - self._draw_direction_pointer() - - def set_connections(self, port1, port2): - """ - Args: - port1 (PortItem): port item object. - port2 (PortItem): port item object. - """ - ports = { - port1.port_type: port1, - port2.port_type: port2 - } - self.input_port = ports[PortTypeEnum.IN.value] - self.output_port = ports[PortTypeEnum.OUT.value] - ports[PortTypeEnum.IN.value].add_pipe(self) - ports[PortTypeEnum.OUT.value].add_pipe(self) - - def disabled(self): - """ - Returns: - bool: true if pipe is a disabled connection. - """ - if self.input_port and self.input_port.node.disabled: - return True - if self.output_port and self.output_port.node.disabled: - return True - return False - - @property - def input_port(self): - return self._input_port - - @input_port.setter - def input_port(self, port): - if isinstance(port, PortItem) or not port: - self._input_port = port - else: - self._input_port = None - - @property - def output_port(self): - return self._output_port - - @output_port.setter - def output_port(self, port): - if isinstance(port, PortItem) or not port: - self._output_port = port - else: - self._output_port = None - - @property - def color(self): - return self._color - - @color.setter - def color(self, color): - self._color = color - - @property - def style(self): - return self._style - - @style.setter - def style(self, style): - self._style = style - - def delete(self): - if self.input_port and self.input_port.connected_pipes: - self.input_port.remove_pipe(self) - if self.output_port and self.output_port.connected_pipes: - self.output_port.remove_pipe(self) - if self.scene(): - self.scene().removeItem(self) - - -class LivePipeItem(PipeItem): - """ - Live Pipe item used for drawing the live connection with the cursor. - """ - - def __init__(self): - super(LivePipeItem, self).__init__() - self.setZValue(Z_VAL_NODE_WIDGET + 1) - - self.color = PipeEnum.ACTIVE_COLOR.value - self.style = PipeEnum.DRAW_TYPE_DASHED.value - self.set_pipe_styling(color=self.color, width=3, style=self.style) - - self.shift_selected = False - - self._idx_pointer = LivePipePolygonItem(self) - self._idx_pointer.setPolygon(self._poly) - self._idx_pointer.setBrush(QtGui.QColor(*self.color).darker(300)) - pen = self._idx_pointer.pen() - pen.setWidth(self.pen().width()) - pen.setColor(self.pen().color()) - pen.setJoinStyle(QtCore.Qt.MiterJoin) - self._idx_pointer.setPen(pen) - - color = self.pen().color() - color.setAlpha(80) - self._idx_text = QtWidgets.QGraphicsTextItem(self) - self._idx_text.setDefaultTextColor(color) - font = self._idx_text.font() - font.setPointSize(7) - self._idx_text.setFont(font) - - def hoverEnterEvent(self, event): - """ - re-implemented back to the base default behaviour or the pipe will - lose it styling when another pipe is selected. - """ - QtWidgets.QGraphicsPathItem.hoverEnterEvent(self, event) - - def draw_path(self, start_port, end_port=None, cursor_pos=None, color=None): - """ - re-implemented to also update the index pointer arrow position. - - Args: - start_port (PortItem): port used to draw the starting point. - end_port (PortItem): port used to draw the end point. - cursor_pos (QtCore.QPointF): cursor position if specified this - will be the draw end point. - color (list[int]): override arrow index pointer color. (r, g, b) - """ - super(LivePipeItem, self).draw_path(start_port, end_port, cursor_pos) - self.draw_index_pointer(start_port, cursor_pos, color) - - def draw_index_pointer(self, start_port, cursor_pos, color=None): - """ - Update the index pointer arrow position and direction when the - live pipe path is redrawn. - - Args: - start_port (PortItem): start port item. - cursor_pos (QtCore.QPoint): cursor scene position. - color (list[int]): override arrow index pointer color. (r, g, b). - """ - text_rect = self._idx_text.boundingRect() - - transform = QtGui.QTransform() - transform.translate(cursor_pos.x(), cursor_pos.y()) - if self.viewer_layout_direction() is LayoutDirectionEnum.VERTICAL.value: - text_pos = ( - cursor_pos.x() + (text_rect.width() / 2.5), - cursor_pos.y() - (text_rect.height() / 2) - ) - if start_port.port_type == PortTypeEnum.OUT.value: - transform.rotate(180) - elif self.viewer_layout_direction() is LayoutDirectionEnum.HORIZONTAL.value: - text_pos = ( - cursor_pos.x() - (text_rect.width() / 2), - cursor_pos.y() - (text_rect.height() * 1.25) - ) - if start_port.port_type == PortTypeEnum.IN.value: - transform.rotate(-90) - else: - transform.rotate(90) - self._idx_text.setPos(*text_pos) - self._idx_text.setPlainText('{}'.format(start_port.name)) - - self._idx_pointer.setPolygon(transform.map(self._poly)) - - pen_color = QtGui.QColor(*PipeEnum.HIGHLIGHT_COLOR.value) - if isinstance(color, (list, tuple)): - pen_color = QtGui.QColor(*color) - - pen = self._idx_pointer.pen() - pen.setColor(pen_color) - self._idx_pointer.setBrush(pen_color.darker(300)) - self._idx_pointer.setPen(pen) - - -class LivePipePolygonItem(QtWidgets.QGraphicsPolygonItem): - """ - Custom live pipe polygon shape. - """ - - def __init__(self, parent): - super(LivePipePolygonItem, self).__init__(parent) - self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True) - - def paint(self, painter, option, widget): - """ - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - painter.save() - painter.setBrush(self.brush()) - painter.setPen(self.pen()) - painter.drawPolygon(self.polygon()) - painter.restore() diff --git a/cuegui/NodeGraphQt/qgraphics/port.py b/cuegui/NodeGraphQt/qgraphics/port.py deleted file mode 100644 index 3d43f7888..000000000 --- a/cuegui/NodeGraphQt/qgraphics/port.py +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/python -from qtpy import QtGui, QtCore, QtWidgets - -from NodeGraphQt.constants import ( - PortTypeEnum, PortEnum, - Z_VAL_PORT, - ITEM_CACHE_MODE) - - -class PortItem(QtWidgets.QGraphicsItem): - """ - Base Port Item. - """ - - def __init__(self, parent=None): - super(PortItem, self).__init__(parent) - self.setAcceptHoverEvents(True) - self.setCacheMode(ITEM_CACHE_MODE) - self.setFlag(self.GraphicsItemFlag.ItemIsSelectable, False) - self.setFlag(self.GraphicsItemFlag.ItemSendsScenePositionChanges, True) - self.setZValue(Z_VAL_PORT) - self._pipes = [] - self._width = PortEnum.SIZE.value - self._height = PortEnum.SIZE.value - self._hovered = False - self._name = 'port' - self._display_name = True - self._color = PortEnum.COLOR.value - self._border_color = PortEnum.BORDER_COLOR.value - self._border_size = 1 - self._port_type = None - self._multi_connection = False - self._locked = False - - def __str__(self): - return '{}.PortItem("{}")'.format(self.__module__, self.name) - - def __repr__(self): - return '{}.PortItem("{}")'.format(self.__module__, self.name) - - def boundingRect(self): - return QtCore.QRectF(0.0, 0.0, - self._width + PortEnum.CLICK_FALLOFF.value, - self._height) - - def paint(self, painter, option, widget): - """ - Draws the circular port. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - painter.save() - - # display falloff collision for debugging - # ---------------------------------------------------------------------- - # pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 80), 0.8) - # pen.setStyle(QtCore.Qt.DotLine) - # painter.setPen(pen) - # painter.drawRect(self.boundingRect()) - # ---------------------------------------------------------------------- - - rect_w = self._width / 1.8 - rect_h = self._height / 1.8 - rect_x = self.boundingRect().center().x() - (rect_w / 2) - rect_y = self.boundingRect().center().y() - (rect_h / 2) - port_rect = QtCore.QRectF(rect_x, rect_y, rect_w, rect_h) - - if self._hovered: - color = QtGui.QColor(*PortEnum.HOVER_COLOR.value) - border_color = QtGui.QColor(*PortEnum.HOVER_BORDER_COLOR.value) - elif self.connected_pipes: - color = QtGui.QColor(*PortEnum.ACTIVE_COLOR.value) - border_color = QtGui.QColor(*PortEnum.ACTIVE_BORDER_COLOR.value) - else: - color = QtGui.QColor(*self.color) - border_color = QtGui.QColor(*self.border_color) - - pen = QtGui.QPen(border_color, 1.8) - painter.setPen(pen) - painter.setBrush(color) - painter.drawEllipse(port_rect) - - if self.connected_pipes and not self._hovered: - painter.setBrush(border_color) - w = port_rect.width() / 2.5 - h = port_rect.height() / 2.5 - rect = QtCore.QRectF(port_rect.center().x() - w / 2, - port_rect.center().y() - h / 2, - w, h) - border_color = QtGui.QColor(*self.border_color) - pen = QtGui.QPen(border_color, 1.6) - painter.setPen(pen) - painter.setBrush(border_color) - painter.drawEllipse(rect) - elif self._hovered: - if self.multi_connection: - pen = QtGui.QPen(border_color, 1.4) - painter.setPen(pen) - painter.setBrush(color) - w = port_rect.width() / 1.8 - h = port_rect.height() / 1.8 - else: - painter.setBrush(border_color) - w = port_rect.width() / 3.5 - h = port_rect.height() / 3.5 - rect = QtCore.QRectF(port_rect.center().x() - w / 2, - port_rect.center().y() - h / 2, - w, h) - painter.drawEllipse(rect) - painter.restore() - - def itemChange(self, change, value): - if change == self.GraphicsItemChange.ItemScenePositionHasChanged: - self.redraw_connected_pipes() - return super(PortItem, self).itemChange(change, value) - - def mousePressEvent(self, event): - super(PortItem, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - super(PortItem, self).mouseReleaseEvent(event) - - def hoverEnterEvent(self, event): - self._hovered = True - super(PortItem, self).hoverEnterEvent(event) - - def hoverLeaveEvent(self, event): - self._hovered = False - super(PortItem, self).hoverLeaveEvent(event) - - def viewer_start_connection(self): - viewer = self.scene().viewer() - viewer.start_live_connection(self) - - def redraw_connected_pipes(self): - if not self.connected_pipes: - return - for pipe in self.connected_pipes: - if self.port_type == PortTypeEnum.IN.value: - pipe.draw_path(self, pipe.output_port) - elif self.port_type == PortTypeEnum.OUT.value: - pipe.draw_path(pipe.input_port, self) - - def add_pipe(self, pipe): - self._pipes.append(pipe) - - def remove_pipe(self, pipe): - self._pipes.remove(pipe) - - @property - def connected_pipes(self): - return self._pipes - - @property - def connected_ports(self): - ports = [] - port_types = { - PortTypeEnum.IN.value: 'output_port', - PortTypeEnum.OUT.value: 'input_port' - } - for pipe in self.connected_pipes: - ports.append(getattr(pipe, port_types[self.port_type])) - return ports - - @property - def hovered(self): - return self._hovered - - @hovered.setter - def hovered(self, value=False): - self._hovered = value - - @property - def node(self): - return self.parentItem() - - @property - def name(self): - return self._name - - @name.setter - def name(self, name=''): - self._name = name.strip() - - @property - def display_name(self): - return self._display_name - - @display_name.setter - def display_name(self, display=True): - self._display_name = display - - @property - def color(self): - return self._color - - @color.setter - def color(self, color=(0, 0, 0, 255)): - self._color = color - self.update() - - @property - def border_color(self): - return self._border_color - - @border_color.setter - def border_color(self, color=(0, 0, 0, 255)): - self._border_color = color - - @property - def border_size(self): - return self._border_size - - @border_size.setter - def border_size(self, size=2): - self._border_size = size - - @property - def locked(self): - return self._locked - - @locked.setter - def locked(self, value=False): - self._locked = value - conn_type = 'multi' if self.multi_connection else 'single' - tooltip = '{}: ({})'.format(self.name, conn_type) - if value: - tooltip += ' (L)' - self.setToolTip(tooltip) - - @property - def multi_connection(self): - return self._multi_connection - - @multi_connection.setter - def multi_connection(self, mode=False): - conn_type = 'multi' if mode else 'single' - self.setToolTip('{}: ({})'.format(self.name, conn_type)) - self._multi_connection = mode - - @property - def port_type(self): - return self._port_type - - @port_type.setter - def port_type(self, port_type): - self._port_type = port_type - - def connect_to(self, port): - if not port: - for pipe in self.connected_pipes: - pipe.delete() - return - if self.scene(): - viewer = self.scene().viewer() - viewer.establish_connection(self, port) - # redraw the ports. - port.update() - self.update() - - def disconnect_from(self, port): - port_types = { - PortTypeEnum.IN.value: 'output_port', - PortTypeEnum.OUT.value: 'input_port' - } - for pipe in self.connected_pipes: - connected_port = getattr(pipe, port_types[self.port_type]) - if connected_port == port: - pipe.delete() - break - # redraw the ports. - port.update() - self.update() - - -class CustomPortItem(PortItem): - """ - Custom port item for drawing custom shape port. - """ - - def __init__(self, parent=None, paint_func=None): - super(CustomPortItem, self).__init__(parent) - self._port_painter = paint_func - - def set_painter(self, func=None): - """ - Set custom paint function for drawing. - - Args: - func (function): paint function. - """ - self._port_painter = func - - def paint(self, painter, option, widget): - """ - Draws the port item. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - if self._port_painter: - rect_w = self._width / 1.8 - rect_h = self._height / 1.8 - rect_x = self.boundingRect().center().x() - (rect_w / 2) - rect_y = self.boundingRect().center().y() - (rect_h / 2) - port_rect = QtCore.QRectF(rect_x, rect_y, rect_w, rect_h) - port_info = { - 'port_type': self.port_type, - 'color': self.color, - 'border_color': self.border_color, - 'multi_connection': self.multi_connection, - 'connected': bool(self.connected_pipes), - 'hovered': self.hovered, - 'locked': self.locked, - } - self._port_painter(painter, port_rect, port_info) - else: - super(CustomPortItem, self).paint(painter, option, widget) diff --git a/cuegui/NodeGraphQt/qgraphics/slicer.py b/cuegui/NodeGraphQt/qgraphics/slicer.py deleted file mode 100644 index 791ac132b..000000000 --- a/cuegui/NodeGraphQt/qgraphics/slicer.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/python -import math - -from qtpy import QtCore, QtGui, QtWidgets - -from NodeGraphQt.constants import Z_VAL_NODE_WIDGET, PipeSlicerEnum - - -class SlicerPipeItem(QtWidgets.QGraphicsPathItem): - """ - Base item used for drawing the pipe connection slicer. - """ - - def __init__(self): - super(SlicerPipeItem, self).__init__() - self.setZValue(Z_VAL_NODE_WIDGET + 2) - - def paint(self, painter, option, widget): - """ - Draws the slicer pipe. - - Args: - painter (QtGui.QPainter): painter used for drawing the item. - option (QtGui.QStyleOptionGraphicsItem): - used to describe the parameters needed to draw. - widget (QtWidgets.QWidget): not used. - """ - color = QtGui.QColor(*PipeSlicerEnum.COLOR.value) - p1 = self.path().pointAtPercent(0) - p2 = self.path().pointAtPercent(1) - size = 6.0 - offset = size / 2 - arrow_size = 4.0 - - painter.save() - painter.setRenderHint(painter.RenderHint.Antialiasing, True) - - font = painter.font() - font.setPointSize(12) - painter.setFont(font) - text = 'slice' - text_x = painter.fontMetrics().width(text) / 2 - text_y = painter.fontMetrics().height() / 1.5 - text_pos = QtCore.QPointF(p1.x() - text_x, p1.y() - text_y) - text_color = QtGui.QColor(*PipeSlicerEnum.COLOR.value) - text_color.setAlpha(80) - painter.setPen(QtGui.QPen( - text_color, PipeSlicerEnum.WIDTH.value, QtCore.Qt.PenStyle.SolidLine - )) - painter.drawText(text_pos, text) - - painter.setPen(QtGui.QPen( - color, PipeSlicerEnum.WIDTH.value, QtCore.Qt.PenStyle.DashDotLine - )) - painter.drawPath(self.path()) - - pen = QtGui.QPen( - color, PipeSlicerEnum.WIDTH.value, QtCore.Qt.PenStyle.SolidLine - ) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin) - painter.setPen(pen) - painter.setBrush(color) - - rect = QtCore.QRectF(p1.x() - offset, p1.y() - offset, size, size) - painter.drawEllipse(rect) - - arrow = QtGui.QPolygonF() - arrow.append(QtCore.QPointF(-arrow_size, arrow_size)) - arrow.append(QtCore.QPointF(0.0, -arrow_size * 0.9)) - arrow.append(QtCore.QPointF(arrow_size, arrow_size)) - - transform = QtGui.QTransform() - transform.translate(p2.x(), p2.y()) - radians = math.atan2(p2.y() - p1.y(), - p2.x() - p1.x()) - degrees = math.degrees(radians) - 90 - transform.rotate(degrees) - - painter.drawPolygon(transform.map(arrow)) - painter.restore() - - def draw_path(self, p1, p2): - path = QtGui.QPainterPath() - path.moveTo(p1) - path.lineTo(p2) - self.setPath(path) diff --git a/cuegui/NodeGraphQt/widgets/__init__.py b/cuegui/NodeGraphQt/widgets/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cuegui/NodeGraphQt/widgets/actions.py b/cuegui/NodeGraphQt/widgets/actions.py deleted file mode 100644 index 54f0b855b..000000000 --- a/cuegui/NodeGraphQt/widgets/actions.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/python -from qtpy import QtCore, QtWidgets - -from NodeGraphQt.constants import ViewerEnum - - -class BaseMenu(QtWidgets.QMenu): - - def __init__(self, *args, **kwargs): - super(BaseMenu, self).__init__(*args, **kwargs) - # text_color = self.palette().text().color().getRgb() - text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), - ViewerEnum.BACKGROUND_COLOR.value)) - selected_color = self.palette().highlight().color().getRgb() - style_dict = { - 'QMenu': { - 'color': 'rgb({0},{1},{2})'.format(*text_color), - 'background-color': 'rgb({0},{1},{2})'.format( - *ViewerEnum.BACKGROUND_COLOR.value - ), - 'border': '1px solid rgba({0},{1},{2},30)'.format(*text_color), - 'border-radius': '3px', - }, - 'QMenu::item': { - 'padding': '5px 18px 2px', - 'background-color': 'transparent', - }, - 'QMenu::item:selected': { - 'color': 'rgb({0},{1},{2})'.format(*text_color), - 'background-color': 'rgba({0},{1},{2},200)' - .format(*selected_color), - }, - 'QMenu::item:disabled': { - 'color': 'rgba({0},{1},{2},60)'.format(*text_color), - 'background-color': 'rgba({0},{1},{2},200)' - .format(*ViewerEnum.BACKGROUND_COLOR.value), - }, - 'QMenu::separator': { - 'height': '1px', - 'background': 'rgba({0},{1},{2}, 50)'.format(*text_color), - 'margin': '4px 8px', - } - } - stylesheet = '' - for css_class, css in style_dict.items(): - style = '{} {{\n'.format(css_class) - for elm_name, elm_val in css.items(): - style += ' {}:{};\n'.format(elm_name, elm_val) - style += '}\n' - stylesheet += style - self.setStyleSheet(stylesheet) - self.node_class = None - self.graph = None - - # disable for issue #142 - # def hideEvent(self, event): - # super(BaseMenu, self).hideEvent(event) - # for a in self.actions(): - # if hasattr(a, 'node_id'): - # a.node_id = None - - def get_menu(self, name, node_id=None): - for action in self.actions(): - menu = action.menu() - if not menu: - continue - if menu.title() == name: - return menu - if node_id and menu.node_class: - node = menu.graph.get_node_by_id(node_id) - if isinstance(node, menu.node_class): - return menu - - def get_menus(self, node_class): - menus = [] - for action in self.actions(): - menu = action.menu() - if menu.node_class: - if issubclass(menu.node_class, node_class): - menus.append(menu) - return menus - - -class GraphAction(QtWidgets.QAction): - - executed = QtCore.Signal(object) - - def __init__(self, *args, **kwargs): - super(GraphAction, self).__init__(*args, **kwargs) - self.graph = None - self.triggered.connect(self._on_triggered) - - def _on_triggered(self): - self.executed.emit(self.graph) - - def get_action(self, name): - for action in self.qmenu.actions(): - if not action.menu() and action.text() == name: - return action - - -class NodeAction(GraphAction): - - executed = QtCore.Signal(object, object) - - def __init__(self, *args, **kwargs): - super(NodeAction, self).__init__(*args, **kwargs) - self.node_id = None - - def _on_triggered(self): - node = self.graph.get_node_by_id(self.node_id) - self.executed.emit(self.graph, node) diff --git a/cuegui/NodeGraphQt/widgets/dialogs.py b/cuegui/NodeGraphQt/widgets/dialogs.py deleted file mode 100644 index 4412f1433..000000000 --- a/cuegui/NodeGraphQt/widgets/dialogs.py +++ /dev/null @@ -1,92 +0,0 @@ -import os - -from qtpy import QtWidgets, QtGui, QtCore - -_current_user_directory = os.path.expanduser('~') - - -def _set_dir(file): - global _current_user_directory - if os.path.isdir(file): - _current_user_directory = file - elif os.path.isfile(file): - _current_user_directory = os.path.split(file)[0] - - -class FileDialog(object): - - @staticmethod - def getSaveFileName(parent=None, title='Save File', file_dir=None, - ext_filter='*'): - if not file_dir: - file_dir = _current_user_directory - file_dlg = QtWidgets.QFileDialog.getSaveFileName( - parent, title, file_dir, ext_filter) - file = file_dlg[0] or None - if file: - _set_dir(file) - return file_dlg - - @staticmethod - def getOpenFileName(parent=None, title='Open File', file_dir=None, - ext_filter='*'): - if not file_dir: - file_dir = _current_user_directory - file_dlg = QtWidgets.QFileDialog.getOpenFileName( - parent, title, file_dir, ext_filter) - file = file_dlg[0] or None - if file: - _set_dir(file) - return file_dlg - - -class BaseDialog(object): - - @staticmethod - def message_dialog(parent=None, text='', title='Message', dialog_icon=None, - custom_icon=None): - dlg = QtWidgets.QMessageBox(parent=parent) - dlg.setWindowTitle(title) - dlg.setInformativeText(text) - dlg.setStandardButtons(QtWidgets.QMessageBox.Ok) - - if custom_icon: - pixmap = QtGui.QPixmap(custom_icon).scaledToHeight( - 32, QtCore.Qt.SmoothTransformation - ) - dlg.setIconPixmap(pixmap) - else: - if dialog_icon == 'information': - dlg.setIcon(dlg.Information) - elif dialog_icon == 'warning': - dlg.setIcon(dlg.Warning) - elif dialog_icon == 'critical': - dlg.setIcon(dlg.Critical) - - dlg.exec_() - - @staticmethod - def question_dialog(parent=None, text='', title='Are you sure?', - dialog_icon=None, custom_icon=None): - dlg = QtWidgets.QMessageBox(parent=parent) - dlg.setWindowTitle(title) - dlg.setInformativeText(text) - dlg.setStandardButtons( - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No - ) - - if custom_icon: - pixmap = QtGui.QPixmap(custom_icon).scaledToHeight( - 32, QtCore.Qt.SmoothTransformation - ) - dlg.setIconPixmap(pixmap) - else: - if dialog_icon == 'information': - dlg.setIcon(dlg.Information) - elif dialog_icon == 'warning': - dlg.setIcon(dlg.Warning) - elif dialog_icon == 'critical': - dlg.setIcon(dlg.Critical) - - result = dlg.exec_() - return bool(result == QtWidgets.QMessageBox.Yes) diff --git a/cuegui/NodeGraphQt/widgets/icons/node_base.png b/cuegui/NodeGraphQt/widgets/icons/node_base.png deleted file mode 100644 index cb18157c4038b931c01a585a37a2bf4fe6988f1c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17542 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 BbC>`C diff --git a/cuegui/NodeGraphQt/widgets/node_graph.py b/cuegui/NodeGraphQt/widgets/node_graph.py deleted file mode 100644 index 66880e252..000000000 --- a/cuegui/NodeGraphQt/widgets/node_graph.py +++ /dev/null @@ -1,125 +0,0 @@ -from qtpy import QtWidgets, QtGui - -from NodeGraphQt.constants import ( - NodeEnum, ViewerEnum, ViewerNavEnum -) - -from NodeGraphQt.widgets.viewer_nav import NodeNavigationWidget - - -class NodeGraphWidget(QtWidgets.QTabWidget): - - def __init__(self, parent=None): - super(NodeGraphWidget, self).__init__(parent) - self.setTabsClosable(True) - self.setTabBarAutoHide(True) - bg_color = QtGui.QColor( - *ViewerEnum.BACKGROUND_COLOR.value).darker(120).getRgb() - text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), bg_color)) - style_dict = { - 'QWidget': { - 'background-color': 'rgb({0},{1},{2})'.format( - *ViewerEnum.BACKGROUND_COLOR.value - ), - }, - 'QTabWidget::pane': { - 'background': 'rgb({0},{1},{2})'.format( - *ViewerEnum.BACKGROUND_COLOR.value - ), - 'border': '0px', - 'border-top': '0px solid rgb({0},{1},{2})'.format(*bg_color), - }, - 'QTabBar::tab': { - 'background': 'rgb({0},{1},{2})'.format(*bg_color), - 'border': '0px solid black', - 'color': 'rgba({0},{1},{2},30)'.format(*text_color), - 'min-width': '10px', - 'padding': '10px 20px', - }, - 'QTabBar::tab:selected': { - 'color': 'rgb({0},{1},{2})'.format(*text_color), - 'background': 'rgb({0},{1},{2})'.format( - *ViewerNavEnum.BACKGROUND_COLOR.value - ), - 'border-top': '1px solid rgb({0},{1},{2})' - .format(*NodeEnum.SELECTED_BORDER_COLOR.value), - }, - 'QTabBar::tab:hover': { - 'color': 'rgb({0},{1},{2})'.format(*text_color), - 'border-top': '1px solid rgb({0},{1},{2})' - .format(*NodeEnum.SELECTED_BORDER_COLOR.value), - } - } - stylesheet = '' - for css_class, css in style_dict.items(): - style = '{} {{\n'.format(css_class) - for elm_name, elm_val in css.items(): - style += ' {}:{};\n'.format(elm_name, elm_val) - style += '}\n' - stylesheet += style - self.setStyleSheet(stylesheet) - - def add_viewer(self, viewer, name, node_id): - self.addTab(viewer, name) - index = self.indexOf(viewer) - self.setTabToolTip(index, node_id) - self.setCurrentIndex(index) - - def remove_viewer(self, viewer): - index = self.indexOf(viewer) - self.removeTab(index) - - -class SubGraphWidget(QtWidgets.QWidget): - - def __init__(self, parent=None, graph=None): - super(SubGraphWidget, self).__init__(parent) - self._graph = graph - self._navigator = NodeNavigationWidget() - self._layout = QtWidgets.QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.setSpacing(1) - self._layout.addWidget(self._navigator) - - self._viewer_widgets = {} - self._viewer_current = None - - @property - def navigator(self): - return self._navigator - - def add_viewer(self, viewer, name, node_id): - if viewer in self._viewer_widgets: - return - - if self._viewer_current: - self.hide_viewer(self._viewer_current) - - self._navigator.add_label_item(name, node_id) - self._layout.addWidget(viewer) - self._viewer_widgets[viewer] = node_id - self._viewer_current = viewer - self._viewer_current.show() - - def remove_viewer(self, viewer=None): - if viewer is None and self._viewer_current: - viewer = self._viewer_current - node_id = self._viewer_widgets.pop(viewer) - self._navigator.remove_label_item(node_id) - self._layout.removeWidget(viewer) - viewer.deleteLater() - - def hide_viewer(self, viewer): - self._layout.removeWidget(viewer) - viewer.hide() - - def show_viewer(self, viewer): - if viewer == self._viewer_current: - self._viewer_current.show() - return - if viewer in self._viewer_widgets: - if self._viewer_current: - self.hide_viewer(self._viewer_current) - self._layout.addWidget(viewer) - self._viewer_current = viewer - self._viewer_current.show() diff --git a/cuegui/NodeGraphQt/widgets/node_widgets.py b/cuegui/NodeGraphQt/widgets/node_widgets.py deleted file mode 100644 index 333df2d7b..000000000 --- a/cuegui/NodeGraphQt/widgets/node_widgets.py +++ /dev/null @@ -1,448 +0,0 @@ -#!/usr/bin/python -from qtpy import QtCore, QtWidgets - -from NodeGraphQt.constants import ViewerEnum, Z_VAL_NODE_WIDGET -from NodeGraphQt.errors import NodeWidgetError - - -class _NodeGroupBox(QtWidgets.QGroupBox): - - def __init__(self, label, parent=None): - super(_NodeGroupBox, self).__init__(parent) - layout = QtWidgets.QVBoxLayout(self) - layout.setSpacing(1) - self.setTitle(label) - - def setTitle(self, text): - margin = (0, 2, 0, 0) if text else (0, 0, 0, 0) - self.layout().setContentsMargins(*margin) - super(_NodeGroupBox, self).setTitle(text) - - def setTitleAlign(self, align='center'): - text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), - ViewerEnum.BACKGROUND_COLOR.value)) - style_dict = { - 'QGroupBox': { - 'background-color': 'rgba(0, 0, 0, 0)', - 'border': '0px solid rgba(0, 0, 0, 0)', - 'margin-top': '1px', - 'padding-bottom': '2px', - 'padding-left': '1px', - 'padding-right': '1px', - 'font-size': '8pt', - }, - 'QGroupBox::title': { - 'subcontrol-origin': 'margin', - 'subcontrol-position': 'top center', - 'color': 'rgba({0}, {1}, {2}, 100)'.format(*text_color), - 'padding': '0px', - } - } - if self.title(): - style_dict['QGroupBox']['padding-top'] = '14px' - else: - style_dict['QGroupBox']['padding-top'] = '2px' - - if align == 'center': - style_dict['QGroupBox::title']['subcontrol-position'] = 'top center' - elif align == 'left': - style_dict['QGroupBox::title']['subcontrol-position'] += 'top left' - style_dict['QGroupBox::title']['margin-left'] = '4px' - elif align == 'right': - style_dict['QGroupBox::title']['subcontrol-position'] += 'top right' - style_dict['QGroupBox::title']['margin-right'] = '4px' - stylesheet = '' - for css_class, css in style_dict.items(): - style = '{} {{\n'.format(css_class) - for elm_name, elm_val in css.items(): - style += ' {}:{};\n'.format(elm_name, elm_val) - style += '}\n' - stylesheet += style - self.setStyleSheet(stylesheet) - - def add_node_widget(self, widget): - self.layout().addWidget(widget) - - def get_node_widget(self): - return self.layout().itemAt(0).widget() - - -class NodeBaseWidget(QtWidgets.QGraphicsProxyWidget): - """ - This is the main wrapper class that allows a ``QtWidgets.QWidget`` to be - added in a :class:`NodeGraphQt.BaseNode` object. - - .. inheritance-diagram:: NodeGraphQt.NodeBaseWidget - :parts: 1 - - Args: - parent (NodeGraphQt.BaseNode.view): parent node view. - name (str): property name for the parent node. - label (str): label text above the embedded widget. - """ - - value_changed = QtCore.Signal(str, object) - """ - Signal triggered when the ``value`` attribute has changed. - - (This is connected to the :meth: `BaseNode.set_property` function when the - widget is added into the node.) - - :parameters: str, object - :emits: property name, propety value - """ - - def __init__(self, parent=None, name=None, label=''): - super(NodeBaseWidget, self).__init__(parent) - self.setZValue(Z_VAL_NODE_WIDGET) - self._name = name - self._label = label - self._node = None - - def setToolTip(self, tooltip): - tooltip = tooltip.replace('\n', '
    ') - tooltip = '{}
    {}'.format(self.get_name(), tooltip) - super(NodeBaseWidget, self).setToolTip(tooltip) - - def on_value_changed(self, *args, **kwargs): - """ - This is the slot function that - Emits the widgets current :meth:`NodeBaseWidget.value` with the - :attr:`NodeBaseWidget.value_changed` signal. - - Args: - args: not used. - kwargs: not used. - - Emits: - str, object: , - """ - self.value_changed.emit(self.get_name(), self.get_value()) - - @property - def type_(self): - """ - Returns the node widget type. - - Returns: - str: widget type. - """ - return str(self.__class__.__name__) - - @property - def node(self): - """ - Returns the node object this widget is embedded in. - (This will return ``None`` if the widget has not been added to - the node yet.) - - Returns: - NodeGraphQt.BaseNode: parent node. - """ - return self._node - - def get_icon(self, name): - """ - Returns the default icon from the Qt framework. - - Returns: - str: icon name. - """ - return self.style().standardIcon(QtWidgets.QStyle.StandardPixmap(name)) - - def get_name(self): - """ - Returns the parent node property name. - - Returns: - str: property name. - """ - return self._name - - def set_name(self, name): - """ - Set the property name for the parent node. - - Important: - The property name must be set before the widget is added to - the node. - - Args: - name (str): property name. - """ - if not name: - return - if self.node: - raise NodeWidgetError( - 'Can\'t set property name widget already added to a Node' - ) - self._name = name - - def get_value(self): - """ - Returns the widgets current value. - - You must re-implement this property to if you're using a custom widget. - - Returns: - str: current property value. - """ - raise NotImplementedError - - def set_value(self, text): - """ - Sets the widgets current value. - - You must re-implement this property to if you're using a custom widget. - - Args: - text (str): new text value. - """ - raise NotImplementedError - - def get_custom_widget(self): - """ - Returns the embedded QWidget used in the node. - - Returns: - QtWidgets.QWidget: nested QWidget - """ - widget = self.widget() - return widget.get_node_widget() - - def set_custom_widget(self, widget): - """ - Set the custom QWidget used in the node. - - Args: - widget (QtWidgets.QWidget): custom. - """ - if self.widget(): - raise NodeWidgetError('Custom node widget already set.') - group = _NodeGroupBox(self._label) - group.add_node_widget(widget) - self.setWidget(group) - - def get_label(self): - """ - Returns the label text displayed above the embedded node widget. - - Returns: - str: label text. - """ - return self._label - - def set_label(self, label=''): - """ - Sets the label text above the embedded widget. - - Args: - label (str): new label ext. - """ - if self.widget(): - self.widget().setTitle(label) - self._label = label - - -class NodeComboBox(NodeBaseWidget): - """ - Displays as a ``QComboBox`` in a node. - - .. inheritance-diagram:: NodeGraphQt.widgets.node_widgets.NodeComboBox - :parts: 1 - - .. note:: - `To embed a` ``QComboBox`` `in a node see func:` - :meth:`NodeGraphQt.BaseNode.add_combo_menu` - """ - - def __init__(self, parent=None, name='', label='', items=None): - super(NodeComboBox, self).__init__(parent, name, label) - self.setZValue(Z_VAL_NODE_WIDGET + 1) - combo = QtWidgets.QComboBox() - combo.setMinimumHeight(24) - combo.addItems(items or []) - combo.currentIndexChanged.connect(self.on_value_changed) - combo.clearFocus() - self.set_custom_widget(combo) - - @property - def type_(self): - return 'ComboNodeWidget' - - def get_value(self): - """ - Returns the widget current text. - - Returns: - str: current text. - """ - combo_widget = self.get_custom_widget() - return str(combo_widget.currentText()) - - def set_value(self, text=''): - combo_widget = self.get_custom_widget() - if type(text) is list: - combo_widget.clear() - combo_widget.addItems(text) - return - if text != self.get_value(): - index = combo_widget.findText(text, QtCore.Qt.MatchFlag.MatchExactly) - combo_widget.setCurrentIndex(index) - - def add_item(self, item): - combo_widget = self.get_custom_widget() - combo_widget.addItem(item) - - def add_items(self, items=None): - if items: - combo_widget = self.get_custom_widget() - combo_widget.addItems(items) - - def all_items(self): - combo_widget = self.get_custom_widget() - return [combo_widget.itemText(i) for i in range(combo_widget.count())] - - def sort_items(self, reversed=False): - items = sorted(self.all_items(), reverse=reversed) - combo_widget = self.get_custom_widget() - combo_widget.clear() - combo_widget.addItems(items) - - def clear(self): - combo_widget = self.get_custom_widget() - combo_widget.clear() - - -class NodeLineEdit(NodeBaseWidget): - """ - Displays as a ``QLineEdit`` in a node. - - .. inheritance-diagram:: NodeGraphQt.widgets.node_widgets.NodeLineEdit - :parts: 1 - - .. note:: - `To embed a` ``QLineEdit`` `in a node see func:` - :meth:`NodeGraphQt.BaseNode.add_text_input` - """ - - def __init__(self, parent=None, name='', label='', text='', placeholder_text=''): - super(NodeLineEdit, self).__init__(parent, name, label) - bg_color = ViewerEnum.BACKGROUND_COLOR.value - text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), - bg_color)) - text_sel_color = text_color - style_dict = { - 'QLineEdit': { - 'background': 'rgba({0},{1},{2},20)'.format(*bg_color), - 'border': '1px solid rgb({0},{1},{2})' - .format(*ViewerEnum.GRID_COLOR.value), - 'border-radius': '3px', - 'color': 'rgba({0},{1},{2},150)'.format(*text_color), - 'selection-background-color': 'rgba({0},{1},{2},100)' - .format(*text_sel_color), - } - } - stylesheet = '' - for css_class, css in style_dict.items(): - style = '{} {{\n'.format(css_class) - for elm_name, elm_val in css.items(): - style += ' {}:{};\n'.format(elm_name, elm_val) - style += '}\n' - stylesheet += style - ledit = QtWidgets.QLineEdit() - ledit.setText(text) - ledit.setPlaceholderText(placeholder_text) - ledit.setStyleSheet(stylesheet) - ledit.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - ledit.editingFinished.connect(self.on_value_changed) - ledit.clearFocus() - self.set_custom_widget(ledit) - self.widget().setMaximumWidth(140) - - @property - def type_(self): - return 'LineEditNodeWidget' - - def get_value(self): - """ - Returns the widgets current text. - - Returns: - str: current text. - """ - return str(self.get_custom_widget().text()) - - def set_value(self, text=''): - """ - Sets the widgets current text. - - Args: - text (str): new text. - """ - if text != self.get_value(): - self.get_custom_widget().setText(text) - self.on_value_changed() - - -class NodeCheckBox(NodeBaseWidget): - """ - Displays as a ``QCheckBox`` in a node. - - .. inheritance-diagram:: NodeGraphQt.widgets.node_widgets.NodeCheckBox - :parts: 1 - - .. note:: - `To embed a` ``QCheckBox`` `in a node see func:` - :meth:`NodeGraphQt.BaseNode.add_checkbox` - """ - - def __init__(self, parent=None, name='', label='', text='', state=False): - super(NodeCheckBox, self).__init__(parent, name, label) - _cbox = QtWidgets.QCheckBox(text) - text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), - ViewerEnum.BACKGROUND_COLOR.value)) - style_dict = { - 'QCheckBox': { - 'color': 'rgba({0},{1},{2},150)'.format(*text_color), - } - } - stylesheet = '' - for css_class, css in style_dict.items(): - style = '{} {{\n'.format(css_class) - for elm_name, elm_val in css.items(): - style += ' {}:{};\n'.format(elm_name, elm_val) - style += '}\n' - stylesheet += style - _cbox.setStyleSheet(stylesheet) - _cbox.setChecked(state) - _cbox.setMinimumWidth(80) - font = _cbox.font() - font.setPointSize(11) - _cbox.setFont(font) - _cbox.stateChanged.connect(self.on_value_changed) - self.set_custom_widget(_cbox) - self.widget().setMaximumWidth(140) - - @property - def type_(self): - return 'CheckboxNodeWidget' - - def get_value(self): - """ - Returns the widget checked state. - - Returns: - bool: checked state. - """ - return self.get_custom_widget().isChecked() - - def set_value(self, state=False): - """ - Sets the widget checked state. - - Args: - state (bool): check state. - """ - if state != self.get_value(): - self.get_custom_widget().setChecked(state) diff --git a/cuegui/NodeGraphQt/widgets/scene.py b/cuegui/NodeGraphQt/widgets/scene.py deleted file mode 100644 index 979bbbd2c..000000000 --- a/cuegui/NodeGraphQt/widgets/scene.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/python -from qtpy import QtGui, QtCore, QtWidgets - -from NodeGraphQt.constants import ViewerEnum - - -class NodeScene(QtWidgets.QGraphicsScene): - - def __init__(self, parent=None): - super(NodeScene, self).__init__(parent) - self._grid_mode = ViewerEnum.GRID_DISPLAY_LINES.value - self._grid_color = ViewerEnum.GRID_COLOR.value - self._bg_color = ViewerEnum.BACKGROUND_COLOR.value - self.setBackgroundBrush(QtGui.QColor(*self._bg_color)) - - def __repr__(self): - cls_name = str(self.__class__.__name__) - return '<{}("{}") object at {}>'.format( - cls_name, self.viewer(), hex(id(self))) - - # def _draw_text(self, painter, pen): - # font = QtGui.QFont() - # font.setPixelSize(48) - # painter.setFont(font) - # parent = self.viewer() - # pos = QtCore.QPoint(20, parent.height() - 20) - # painter.setPen(pen) - # painter.drawText(parent.mapToScene(pos), 'Not Editable') - - def _draw_grid(self, painter, rect, pen, grid_size): - """ - draws the grid lines in the scene. - - Args: - painter (QtGui.QPainter): painter object. - rect (QtCore.QRectF): rect object. - pen (QtGui.QPen): pen object. - grid_size (int): grid size. - """ - left = int(rect.left()) - right = int(rect.right()) - top = int(rect.top()) - bottom = int(rect.bottom()) - - first_left = left - (left % grid_size) - first_top = top - (top % grid_size) - - lines = [] - lines.extend([ - QtCore.QLineF(x, top, x, bottom) - for x in range(first_left, right, grid_size) - ]) - lines.extend([ - QtCore.QLineF(left, y, right, y) - for y in range(first_top, bottom, grid_size)] - ) - - painter.setPen(pen) - painter.drawLines(lines) - - def _draw_dots(self, painter, rect, pen, grid_size): - """ - draws the grid dots in the scene. - - Args: - painter (QtGui.QPainter): painter object. - rect (QtCore.QRectF): rect object. - pen (QtGui.QPen): pen object. - grid_size (int): grid size. - """ - zoom = self.viewer().get_zoom() - if zoom < 0: - grid_size = int(abs(zoom) / 0.3 + 1) * grid_size - - left = int(rect.left()) - right = int(rect.right()) - top = int(rect.top()) - bottom = int(rect.bottom()) - - first_left = left - (left % grid_size) - first_top = top - (top % grid_size) - - pen.setWidth(grid_size / 10) - painter.setPen(pen) - - [painter.drawPoint(int(x), int(y)) - for x in range(first_left, right, grid_size) - for y in range(first_top, bottom, grid_size)] - - def drawBackground(self, painter, rect): - super(NodeScene, self).drawBackground(painter, rect) - - painter.save() - painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, False) - painter.setBrush(self.backgroundBrush()) - - if self._grid_mode is ViewerEnum.GRID_DISPLAY_DOTS.value: - pen = QtGui.QPen(QtGui.QColor(*self.grid_color), 0.65) - self._draw_dots(painter, rect, pen, ViewerEnum.GRID_SIZE.value) - - elif self._grid_mode is ViewerEnum.GRID_DISPLAY_LINES.value: - zoom = self.viewer().get_zoom() - if zoom > -0.5: - pen = QtGui.QPen(QtGui.QColor(*self.grid_color), 0.65) - self._draw_grid( - painter, rect, pen, ViewerEnum.GRID_SIZE.value - ) - - color = QtGui.QColor(*self._bg_color).darker(200) - if zoom < -0.0: - color = color.darker(100 - int(zoom * 110)) - pen = QtGui.QPen(color, 0.65) - self._draw_grid( - painter, rect, pen, ViewerEnum.GRID_SIZE.value * 8 - ) - - painter.restore() - - def mousePressEvent(self, event): - selected_nodes = self.viewer().selected_nodes() - if self.viewer(): - self.viewer().sceneMousePressEvent(event) - super(NodeScene, self).mousePressEvent(event) - keep_selection = any([ - event.button() == QtCore.Qt.MouseButton.MiddleButton, - event.button() == QtCore.Qt.MouseButton.RightButton, - event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier - ]) - if keep_selection: - for node in selected_nodes: - node.setSelected(True) - - def mouseMoveEvent(self, event): - if self.viewer(): - self.viewer().sceneMouseMoveEvent(event) - super(NodeScene, self).mouseMoveEvent(event) - - def mouseReleaseEvent(self, event): - if self.viewer(): - self.viewer().sceneMouseReleaseEvent(event) - super(NodeScene, self).mouseReleaseEvent(event) - - def viewer(self): - return self.views()[0] if self.views() else None - - @property - def grid_mode(self): - return self._grid_mode - - @grid_mode.setter - def grid_mode(self, mode=None): - if mode is None: - mode = ViewerEnum.GRID_DISPLAY_LINES.value - self._grid_mode = mode - - @property - def grid_color(self): - return self._grid_color - - @grid_color.setter - def grid_color(self, color=(0, 0, 0)): - self._grid_color = color - - @property - def background_color(self): - return self._bg_color - - @background_color.setter - def background_color(self, color=(0, 0, 0)): - self._bg_color = color - self.setBackgroundBrush(QtGui.QColor(*self._bg_color)) diff --git a/cuegui/NodeGraphQt/widgets/tab_search.py b/cuegui/NodeGraphQt/widgets/tab_search.py deleted file mode 100644 index 3e60a5521..000000000 --- a/cuegui/NodeGraphQt/widgets/tab_search.py +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/python -import re -from collections import OrderedDict - -from qtpy import QtCore, QtWidgets, QtGui - -from NodeGraphQt.constants import ViewerEnum, ViewerNavEnum - - -class TabSearchCompleter(QtWidgets.QCompleter): - """ - QCompleter adapted from: - https://stackoverflow.com/questions/5129211/qcompleter-custom-completion-rules - """ - - def __init__(self, nodes=None, parent=None): - super(TabSearchCompleter, self).__init__(nodes, parent) - self.setCompletionMode(self.CompletionMode.PopupCompletion) - self.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) - self._local_completion_prefix = '' - self._using_orig_model = False - self._source_model = None - self._filter_model = None - - def splitPath(self, path): - self._local_completion_prefix = path - self.updateModel() - - if self._filter_model.rowCount() == 0: - self._using_orig_model = False - self._filter_model.setSourceModel(QtCore.QStringListModel([])) - return [] - return [] - - def updateModel(self): - if not self._using_orig_model: - self._filter_model.setSourceModel(self._source_model) - # # https://doc.qt.io/qtforpython-6/overviews/qtcore-changes-qt6.html#the-qregularexpression-class - # pattern = QtCore.QRegExp(self._local_completion_prefix, - # QtCore.Qt.CaseSensitivity.CaseInsensitive, - # QtCore.QRegExp.FixedString) - # self._filter_model.setFilterRegExp(pattern) - # TODO: review these changes - self._filter_model.setFilterCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) - self._filter_model.setFilterFixedString(self._local_completion_prefix) - - def setModel(self, model): - self._source_model = model - self._filter_model = QtCore.QSortFilterProxyModel(self) - self._filter_model.setSourceModel(self._source_model) - super(TabSearchCompleter, self).setModel(self._filter_model) - self._using_orig_model = True - - -class TabSearchLineEditWidget(QtWidgets.QLineEdit): - - tab_pressed = QtCore.Signal() - - def __init__(self, parent=None): - super(TabSearchLineEditWidget, self).__init__(parent) - self.setAttribute(QtCore.Qt.WidgetAttribute.WA_MacShowFocusRect, 0) - self.setMinimumSize(200, 22) - # text_color = self.palette().text().color().getRgb() - text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), - ViewerEnum.BACKGROUND_COLOR.value)) - selected_color = self.palette().highlight().color().getRgb() - style_dict = { - 'QLineEdit': { - 'color': 'rgb({0},{1},{2})'.format(*text_color), - 'border': '1px solid rgb({0},{1},{2})'.format( - *selected_color - ), - 'border-radius': '3px', - 'padding': '2px 4px', - 'margin': '2px 4px 8px 4px', - 'background': 'rgb({0},{1},{2})'.format( - *ViewerNavEnum.BACKGROUND_COLOR.value - ), - 'selection-background-color': 'rgba({0},{1},{2},200)' - .format(*selected_color), - } - } - stylesheet = '' - for css_class, css in style_dict.items(): - style = '{} {{\n'.format(css_class) - for elm_name, elm_val in css.items(): - style += ' {}:{};\n'.format(elm_name, elm_val) - style += '}\n' - stylesheet += style - self.setStyleSheet(stylesheet) - - def keyPressEvent(self, event): - super(TabSearchLineEditWidget, self).keyPressEvent(event) - if event.key() == QtCore.Qt.Key.Key_Tab: - self.tab_pressed.emit() - - -class TabSearchMenuWidget(QtWidgets.QMenu): - - search_submitted = QtCore.Signal(str) - - def __init__(self, node_dict=None): - super(TabSearchMenuWidget, self).__init__() - - self.line_edit = TabSearchLineEditWidget() - self.line_edit.tab_pressed.connect(self._close) - - self._node_dict = node_dict or {} - if self._node_dict: - self._generate_items_from_node_dict() - - search_widget = QtWidgets.QWidgetAction(self) - search_widget.setDefaultWidget(self.line_edit) - self.addAction(search_widget) - - # text_color = self.palette().text().color().getRgb() - text_color = tuple(map(lambda i, j: i - j, (255, 255, 255), - ViewerEnum.BACKGROUND_COLOR.value)) - selected_color = self.palette().highlight().color().getRgb() - style_dict = { - 'QMenu': { - 'color': 'rgb({0},{1},{2})'.format(*text_color), - 'background-color': 'rgb({0},{1},{2})'.format( - *ViewerEnum.BACKGROUND_COLOR.value - ), - 'border': '1px solid rgba({0},{1},{2},30)'.format(*text_color), - 'border-radius': '3px', - }, - 'QMenu::item': { - 'padding': '5px 18px 2px', - 'background-color': 'transparent', - }, - 'QMenu::item:selected': { - 'color': 'rgb({0},{1},{2})'.format(*text_color), - 'background-color': 'rgba({0},{1},{2},200)' - .format(*selected_color), - }, - 'QMenu::separator': { - 'height': '1px', - 'background': 'rgba({0},{1},{2}, 50)'.format(*text_color), - 'margin': '4px 8px', - } - } - self._menu_stylesheet = '' - for css_class, css in style_dict.items(): - style = '{} {{\n'.format(css_class) - for elm_name, elm_val in css.items(): - style += ' {}:{};\n'.format(elm_name, elm_val) - style += '}\n' - self._menu_stylesheet += style - self.setStyleSheet(self._menu_stylesheet) - - self._actions = {} - self._menus = {} - self._searched_actions = [] - - self._block_submit = False - - self.rebuild = False - - self._wire_signals() - - def __repr__(self): - return '<{} at {}>'.format(self.__class__.__name__, hex(id(self))) - - def keyPressEvent(self, event): - super(TabSearchMenuWidget, self).keyPressEvent(event) - self.line_edit.keyPressEvent(event) - - @staticmethod - def _fuzzy_finder(key, collection): - suggestions = [] - pattern = '.*?'.join(key.lower()) - regex = re.compile(pattern) - for item in collection: - match = regex.search(item.lower()) - if match: - suggestions.append((len(match.group()), match.start(), item)) - - return [x for _, _, x in sorted(suggestions)] - - def _wire_signals(self): - self.line_edit.returnPressed.connect(self._on_search_submitted) - self.line_edit.textChanged.connect(self._on_text_changed) - - def _on_text_changed(self, text): - self._clear_actions() - - if not text: - self._set_menu_visible(True) - return - - self._set_menu_visible(False) - - action_names = self._fuzzy_finder(text, self._actions.keys()) - - self._searched_actions = [self._actions[name] for name in action_names] - self.addActions(self._searched_actions) - - if self._searched_actions: - self.setActiveAction(self._searched_actions[0]) - - def _clear_actions(self): - for action in self._searched_actions: - self.removeAction(action) - action.triggered.connect(self._on_search_submitted) - del self._searched_actions[:] - - def _set_menu_visible(self, visible): - for menu in self._menus.values(): - menu.menuAction().setVisible(visible) - - def _close(self): - self._set_menu_visible(False) - self.setVisible(False) - self.menuAction().setVisible(False) - self._block_submit = True - - def _show(self): - self.line_edit.setText("") - self.line_edit.setFocus() - self._set_menu_visible(True) - self._block_submit = False - self.exec_(QtGui.QCursor.pos()) - - def _on_search_submitted(self): - if not self._block_submit: - action = self.sender() - if type(action) is not QtWidgets.QAction: - if len(self._searched_actions) > 0: - action = self._searched_actions[0] - else: - self._close() - return - - text = action.text() - node_type = self._node_dict.get(text) - if node_type: - self.search_submitted.emit(node_type) - - self._close() - - def build_menu_tree(self): - node_types = sorted(self._node_dict.values()) - node_names = sorted(self._node_dict.keys()) - menu_tree = OrderedDict() - - max_depth = 0 - for node_type in node_types: - trees = '.'.join(node_type.split('.')[:-1]).split('::') - for depth, menu_name in enumerate(trees): - new_menu = None - menu_path = '::'.join(trees[:depth + 1]) - if depth in menu_tree.keys(): - if menu_name not in menu_tree[depth].keys(): - new_menu = QtWidgets.QMenu(menu_name) - new_menu.keyPressEvent = self.keyPressEvent - new_menu.setStyleSheet(self._menu_stylesheet) - menu_tree[depth][menu_path] = new_menu - else: - new_menu = QtWidgets.QMenu(menu_name) - new_menu.setStyleSheet(self._menu_stylesheet) - menu_tree[depth] = {menu_path: new_menu} - if depth > 0 and new_menu: - new_menu.parentPath = '::'.join(trees[:depth]) - - max_depth = max(max_depth, depth) - - for i in range(max_depth+1): - menus = menu_tree[i] - for menu_path, menu in menus.items(): - self._menus[menu_path] = menu - if i == 0: - self.addMenu(menu) - else: - parent_menu = self._menus[menu.parentPath] - parent_menu.addMenu(menu) - - for name in node_names: - action = QtWidgets.QAction(name, self) - action.setText(name) - action.triggered.connect(self._on_search_submitted) - self._actions[name] = action - - menu_name = self._node_dict[name] - menu_path = '.'.join(menu_name.split('.')[:-1]) - - if menu_path in self._menus.keys(): - self._menus[menu_path].addAction(action) - else: - self.addAction(action) - - def set_nodes(self, node_dict=None): - if not self._node_dict or self.rebuild: - self._node_dict.clear() - self._clear_actions() - self._set_menu_visible(False) - for menu in self._menus.values(): - self.removeAction(menu.menuAction()) - self._actions.clear() - self._menus.clear() - for name, node_types in node_dict.items(): - if len(node_types) == 1: - self._node_dict[name] = node_types[0] - continue - for node_id in node_types: - self._node_dict['{} ({})'.format(name, node_id)] = node_id - self.build_menu_tree() - self.rebuild = False - - self._show() diff --git a/cuegui/NodeGraphQt/widgets/viewer.py b/cuegui/NodeGraphQt/widgets/viewer.py deleted file mode 100644 index 9d8489a43..000000000 --- a/cuegui/NodeGraphQt/widgets/viewer.py +++ /dev/null @@ -1,1653 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -import math -from packaging import version - -from qtpy import QtGui, QtCore, QtWidgets - -from NodeGraphQt.base.menu import BaseMenu -from NodeGraphQt.constants import ( - LayoutDirectionEnum, - PortTypeEnum, - PipeEnum, - PipeLayoutEnum, - ViewerEnum, - Z_VAL_PIPE, -) -from NodeGraphQt.qgraphics.node_abstract import AbstractNodeItem -from NodeGraphQt.qgraphics.node_backdrop import BackdropNodeItem -from NodeGraphQt.qgraphics.pipe import PipeItem, LivePipeItem -from NodeGraphQt.qgraphics.port import PortItem -from NodeGraphQt.qgraphics.slicer import SlicerPipeItem -from NodeGraphQt.widgets.dialogs import BaseDialog, FileDialog -from NodeGraphQt.widgets.scene import NodeScene -from NodeGraphQt.widgets.tab_search import TabSearchMenuWidget - -ZOOM_MIN = -0.95 -ZOOM_MAX = 2.0 - - -class NodeViewer(QtWidgets.QGraphicsView): - """ - The widget interface used for displaying the scene and nodes. - - functions in this class should mainly be called by the - class:`NodeGraphQt.NodeGraph` class. - """ - - # node viewer signals. - # (some of these signals are called by port & node items and connected - # to the node graph slot functions) - moved_nodes = QtCore.Signal(object) - search_triggered = QtCore.Signal(str, tuple) - connection_sliced = QtCore.Signal(list) - connection_changed = QtCore.Signal(list, list) - insert_node = QtCore.Signal(object, str, object) - node_name_changed = QtCore.Signal(str, str) - node_backdrop_updated = QtCore.Signal(str, str, object) - - # pass through signals that are translated into "NodeGraph()" signals. - node_selected = QtCore.Signal(str) - node_selection_changed = QtCore.Signal(list, list) - node_double_clicked = QtCore.Signal(str) - data_dropped = QtCore.Signal(QtCore.QMimeData, object) - context_menu_prompt = QtCore.Signal(str, object) - - def __init__(self, parent=None, undo_stack=None): - """ - Args: - parent: - undo_stack (QtGui.QUndoStack): undo stack from the parent - graph controller. - """ - super(NodeViewer, self).__init__(parent) - - self.setScene(NodeScene(self)) - self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) - self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setViewportUpdateMode(QtWidgets.QGraphicsView.ViewportUpdateMode.FullViewportUpdate) - self.setCacheMode(QtWidgets.QGraphicsView.CacheModeFlag.CacheBackground) - self.setOptimizationFlag( - QtWidgets.QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing) - - self.setAcceptDrops(True) - self.resize(850, 800) - - self._scene_range = QtCore.QRectF( - 0, 0, self.size().width(), self.size().height()) - self._update_scene() - self._last_size = self.size() - - self._layout_direction = LayoutDirectionEnum.HORIZONTAL.value - - self._pipe_layout = PipeLayoutEnum.CURVED.value - self._detached_port = None - self._start_port = None - self._origin_pos = None - self._previous_pos = QtCore.QPoint(int(self.width() / 2), - int(self.height() / 2)) - self._prev_selection_nodes = [] - self._prev_selection_pipes = [] - self._node_positions = {} - - self._rubber_band = QtWidgets.QRubberBand( - QtWidgets.QRubberBand.Shape.Rectangle, self - ) - self._rubber_band.isActive = False - - text_color = QtGui.QColor(*tuple(map( - lambda i, j: i - j, (255, 255, 255), - ViewerEnum.BACKGROUND_COLOR.value - ))) - text_color.setAlpha(50) - self._cursor_text = QtWidgets.QGraphicsTextItem() - self._cursor_text.setFlag(self._cursor_text.GraphicsItemFlag.ItemIsSelectable, False) - self._cursor_text.setDefaultTextColor(text_color) - self._cursor_text.setZValue(Z_VAL_PIPE - 1) - font = self._cursor_text.font() - font.setPointSize(7) - self._cursor_text.setFont(font) - self.scene().addItem(self._cursor_text) - - self._LIVE_PIPE = LivePipeItem() - self._LIVE_PIPE.setVisible(False) - self.scene().addItem(self._LIVE_PIPE) - - self._SLICER_PIPE = SlicerPipeItem() - self._SLICER_PIPE.setVisible(False) - self.scene().addItem(self._SLICER_PIPE) - - self._search_widget = TabSearchMenuWidget() - self._search_widget.search_submitted.connect(self._on_search_submitted) - - # workaround fix for shortcuts from the non-native menu. - # actions don't seem to trigger so we create a hidden menu bar. - self._ctx_menu_bar = QtWidgets.QMenuBar(self) - self._ctx_menu_bar.setNativeMenuBar(False) - # shortcuts don't work with "setVisibility(False)". - self._ctx_menu_bar.setMaximumSize(0, 0) - - # context menus. - self._ctx_graph_menu = BaseMenu('NodeGraph', self) - self._ctx_node_menu = BaseMenu('Nodes', self) - - if undo_stack: - self._undo_action = undo_stack.createUndoAction(self, '&Undo') - self._redo_action = undo_stack.createRedoAction(self, '&Redo') - else: - self._undo_action = None - self._redo_action = None - - self._build_context_menus() - - self.acyclic = True - self.pipe_collision = False - self.pipe_slicing = True - - self.LMB_state = False - self.RMB_state = False - self.MMB_state = False - self.ALT_state = False - self.CTRL_state = False - self.SHIFT_state = False - self.COLLIDING_state = False - - # connection constrains. - # TODO: maybe this should be a reference to the graph model instead? - self.accept_connection_types = None - self.reject_connection_types = None - - def __repr__(self): - return '<{}() object at {}>'.format( - self.__class__.__name__, hex(id(self))) - - def focusInEvent(self, event): - """ - Args: - event (QtGui.QFocusEvent): focus event. - """ - # workaround fix: Re-populate the QMenuBar so the QAction shotcuts don't - # conflict with parent existing host app. - self._ctx_menu_bar.addMenu(self._ctx_graph_menu) - self._ctx_menu_bar.addMenu(self._ctx_node_menu) - return super(NodeViewer, self).focusInEvent(event) - - def focusOutEvent(self, event): - """ - Args: - event (QtGui.QFocusEvent): focus event. - """ - # workaround fix: Clear the QMenuBar so the QAction shotcuts don't - # conflict with existing parent host app. - self._ctx_menu_bar.clear() - return super(NodeViewer, self).focusOutEvent(event) - - # --- private --- - - def _build_context_menus(self): - """ - Build context menu for the node graph. - """ - # "node context menu" disabled by default and enabled when a action - # is added through the "NodesMenu" interface. - self._ctx_node_menu.setDisabled(True) - - # add the base menus. - self._ctx_menu_bar.addMenu(self._ctx_graph_menu) - self._ctx_menu_bar.addMenu(self._ctx_node_menu) - - # setup the undo and redo actions. - if self._undo_action and self._redo_action: - self._undo_action.setShortcuts(QtGui.QKeySequence.StandardKey.Undo) - self._redo_action.setShortcuts(QtGui.QKeySequence.StandardKey.Redo) - if version.parse(QtCore.qVersion()) >= version.parse('5.10'): - self._undo_action.setShortcutVisibleInContextMenu(True) - self._redo_action.setShortcutVisibleInContextMenu(True) - - # undo & redo always at the top of the "node graph context menu". - self._ctx_graph_menu.addAction(self._undo_action) - self._ctx_graph_menu.addAction(self._redo_action) - self._ctx_graph_menu.addSeparator() - - def _set_viewer_zoom(self, value, sensitivity=None, pos=None): - """ - Sets the zoom level. - - Args: - value (float): zoom factor. - sensitivity (float): zoom sensitivity. - pos (QtCore.QPoint): mapped position. - """ - if pos: - pos = self.mapToScene(pos) - if sensitivity is None: - scale = 1.001 ** value - self.scale(scale, scale, pos) - return - - if value == 0.0: - return - - scale = (0.9 + sensitivity) if value < 0.0 else (1.1 - sensitivity) - zoom = self.get_zoom() - if ZOOM_MIN >= zoom: - if scale == 0.9: - return - if ZOOM_MAX <= zoom: - if scale == 1.1: - return - self.scale(scale, scale, pos) - - def _set_viewer_pan(self, pos_x, pos_y): - """ - Set the viewer in panning mode. - - Args: - pos_x (float): x pos. - pos_y (float): y pos. - """ - self._scene_range.adjust(pos_x, pos_y, pos_x, pos_y) - self._update_scene() - - def scale(self, sx, sy, pos=None): - scale = [sx, sx] - center = pos or self._scene_range.center() - w = self._scene_range.width() / scale[0] - h = self._scene_range.height() / scale[1] - self._scene_range = QtCore.QRectF( - center.x() - (center.x() - self._scene_range.left()) / scale[0], - center.y() - (center.y() - self._scene_range.top()) / scale[1], - w, h - ) - self._update_scene() - - def _update_scene(self): - """ - Redraw the scene. - """ - self.setSceneRect(self._scene_range) - self.fitInView(self._scene_range, QtCore.Qt.AspectRatioMode.KeepAspectRatio) - - def _combined_rect(self, nodes): - """ - Returns a QRectF with the combined size of the provided node items. - - Args: - nodes (list[AbstractNodeItem]): list of node qgraphics items. - - Returns: - QtCore.QRectF: combined rect - """ - group = self.scene().createItemGroup(nodes) - rect = group.boundingRect() - self.scene().destroyItemGroup(group) - return rect - - def _items_near(self, pos, item_type=None, width=20, height=20): - """ - Filter node graph items from the specified position, width and - height area. - - Args: - pos (QtCore.QPoint): scene pos. - item_type: filter item type. (optional) - width (int): width area. - height (int): height area. - - Returns: - list: qgraphics items from the scene. - """ - x, y = pos.x() - width, pos.y() - height - rect = QtCore.QRectF(x, y, width, height) - items = [] - excl = [self._LIVE_PIPE, self._SLICER_PIPE] - for item in self.scene().items(rect): - if item in excl: - continue - if not item_type or isinstance(item, item_type): - items.append(item) - return items - - def _on_search_submitted(self, node_type): - """ - Slot function triggered when the ``TabSearchMenuWidget`` has - submitted a search. - - This will emit the "search_triggered" signal and tell the parent node - graph to create a new node object. - - Args: - node_type (str): node type identifier. - """ - pos = self.mapToScene(self._previous_pos) - self.search_triggered.emit(node_type, (pos.x(), pos.y())) - - def _on_pipes_sliced(self, path): - """ - Triggered when the slicer pipe is active - - Args: - path (QtGui.QPainterPath): slicer path. - """ - ports = [] - for i in self.scene().items(path): - if isinstance(i, PipeItem) and i != self._LIVE_PIPE: - if any([i.input_port.locked, i.output_port.locked]): - continue - ports.append([i.input_port, i.output_port]) - self.connection_sliced.emit(ports) - - # --- reimplemented events --- - - def resizeEvent(self, event): - w, h = self.size().width(), self.size().height() - if 0 in [w, h]: - self.resize(self._last_size) - delta = max(w / self._last_size.width(), h / self._last_size.height()) - self._set_viewer_zoom(delta) - self._last_size = self.size() - super(NodeViewer, self).resizeEvent(event) - - def contextMenuEvent(self, event): - self.RMB_state = False - - ctx_menu = None - ctx_menus = self.context_menus() - - prompted_data = None, None - - if ctx_menus['nodes'].isEnabled(): - pos = self.mapToScene(self._previous_pos) - items = self._items_near(pos) - nodes = [i for i in items if isinstance(i, AbstractNodeItem)] - if nodes: - node = nodes[0] - ctx_menu = ctx_menus['nodes'].get_menu(node.type_, node.id) - if ctx_menu: - for action in ctx_menu.actions(): - if not action.menu(): - action.node_id = node.id - prompted_data = 'nodes', node.id - - if not ctx_menu: - ctx_menu = ctx_menus['graph'] - prompted_data = 'graph', None - - if len(ctx_menu.actions()) > 0: - if ctx_menu.isEnabled(): - self.context_menu_prompt.emit( - prompted_data[0], prompted_data[1] - ) - ctx_menu.exec_(event.globalPos()) - else: - return super(NodeViewer, self).contextMenuEvent(event) - - return super(NodeViewer, self).contextMenuEvent(event) - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.MouseButton.LeftButton: - self.LMB_state = True - elif event.button() == QtCore.Qt.MouseButton.RightButton: - self.RMB_state = True - elif event.button() == QtCore.Qt.MouseButton.MiddleButton: - self.MMB_state = True - - self._origin_pos = event.pos() - self._previous_pos = event.pos() - (self._prev_selection_nodes, - self._prev_selection_pipes) = self.selected_items() - - # close tab search - if self._search_widget.isVisible(): - self.tab_search_toggle() - - # cursor pos. - map_pos = self.mapToScene(event.pos()) - - # pipe slicer enabled. - if self.pipe_slicing: - slicer_mode = all([ - self.ALT_state, self.SHIFT_state, self.LMB_state - ]) - if slicer_mode: - self._SLICER_PIPE.draw_path(map_pos, map_pos) - self._SLICER_PIPE.setVisible(True) - return - - # pan mode. - if self.ALT_state: - return - - items = self._items_near(map_pos, None, 20, 20) - pipes = [] - nodes = [] - backdrop = None - for itm in items: - if isinstance(itm, PipeItem): - pipes.append(itm) - elif isinstance(itm, AbstractNodeItem): - if isinstance(itm, BackdropNodeItem): - backdrop = itm - continue - nodes.append(itm) - - if nodes: - self.MMB_state = False - - # record the node selection as "self.selected_nodes()" is not updated - # here on the mouse press event. - selection = set([]) - - if self.LMB_state: - # toggle extend node selection. - if self.SHIFT_state: - if items and backdrop == items[0]: - backdrop.selected = not backdrop.selected - if backdrop.selected: - selection.add(backdrop) - for n in backdrop.get_nodes(): - n.selected = backdrop.selected - if backdrop.selected: - selection.add(n) - else: - for node in nodes: - node.selected = not node.selected - if node.selected: - selection.add(node) - # unselected nodes with the "ctrl" key. - elif self.CTRL_state: - if items and backdrop == items[0]: - backdrop.selected = False - else: - for node in nodes: - node.selected = False - # if no modifier keys then add to selection set. - else: - if backdrop: - selection.add(backdrop) - for n in backdrop.get_nodes(): - selection.add(n) - for node in nodes: - if node.selected: - selection.add(node) - - selection.update(self.selected_nodes()) - - # update the recorded node positions. - self._node_positions.update({n: n.xy_pos for n in selection}) - - # show selection marquee. - if self.LMB_state and not items: - rect = QtCore.QRect(self._previous_pos, QtCore.QSize()) - rect = rect.normalized() - map_rect = self.mapToScene(rect).boundingRect() - self.scene().update(map_rect) - self._rubber_band.setGeometry(rect) - self._rubber_band.isActive = True - - # stop here so we don't select a node. - # (ctrl modifier can be used for something else in future.) - if self.CTRL_state: - return - - # allow new live pipe with the shift modifier on port that allow - # for multi connection. - if self.SHIFT_state: - if pipes: - pipes[0].reset() - port = pipes[0].port_from_pos(map_pos, reverse=True) - if not port.locked and port.multi_connection: - self._cursor_text.setPlainText('') - self._cursor_text.setVisible(False) - self.start_live_connection(port) - - # return here as the default behaviour unselects nodes with - # the shift modifier. - return - - if not self._LIVE_PIPE.isVisible(): - super(NodeViewer, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - if event.button() == QtCore.Qt.MouseButton.LeftButton: - self.LMB_state = False - elif event.button() == QtCore.Qt.MouseButton.RightButton: - self.RMB_state = False - elif event.button() == QtCore.Qt.MouseButton.MiddleButton: - self.MMB_state = False - - # hide pipe slicer. - if self._SLICER_PIPE.isVisible(): - self._on_pipes_sliced(self._SLICER_PIPE.path()) - p = QtCore.QPointF(0.0, 0.0) - self._SLICER_PIPE.draw_path(p, p) - self._SLICER_PIPE.setVisible(False) - - # hide selection marquee - if self._rubber_band.isActive: - self._rubber_band.isActive = False - if self._rubber_band.isVisible(): - rect = self._rubber_band.rect() - map_rect = self.mapToScene(rect).boundingRect() - self._rubber_band.hide() - - rect = QtCore.QRect(self._origin_pos, event.pos()).normalized() - rect_items = self.scene().items( - self.mapToScene(rect).boundingRect() - ) - node_ids = [] - for item in rect_items: - if isinstance(item, AbstractNodeItem): - node_ids.append(item.id) - - # emit the node selection signals. - if node_ids: - prev_ids = [ - n.id for n in self._prev_selection_nodes - if not n.selected - ] - self.node_selected.emit(node_ids[0]) - self.node_selection_changed.emit(node_ids, prev_ids) - - self.scene().update(map_rect) - return - - # find position changed nodes and emit signal. - moved_nodes = { - n: xy_pos for n, xy_pos in self._node_positions.items() - if n.xy_pos != xy_pos - } - # only emit of node is not colliding with a pipe. - if moved_nodes and not self.COLLIDING_state: - self.moved_nodes.emit(moved_nodes) - - # reset recorded positions. - self._node_positions = {} - - # emit signal if selected node collides with pipe. - # Note: if collide state is true then only 1 node is selected. - nodes, pipes = self.selected_items() - if self.COLLIDING_state and nodes and pipes: - self.insert_node.emit(pipes[0], nodes[0].id, moved_nodes) - - # emit node selection changed signal. - prev_ids = [n.id for n in self._prev_selection_nodes if not n.selected] - node_ids = [n.id for n in nodes if n not in self._prev_selection_nodes] - self.node_selection_changed.emit(node_ids, prev_ids) - - super(NodeViewer, self).mouseReleaseEvent(event) - - def mouseMoveEvent(self, event): - if self.ALT_state and self.SHIFT_state: - if self.pipe_slicing: - if self.LMB_state and self._SLICER_PIPE.isVisible(): - p1 = self._SLICER_PIPE.path().pointAtPercent(0) - p2 = self.mapToScene(self._previous_pos) - self._SLICER_PIPE.draw_path(p1, p2) - self._SLICER_PIPE.show() - self._previous_pos = event.pos() - super(NodeViewer, self).mouseMoveEvent(event) - return - - if self.MMB_state and self.ALT_state: - pos_x = (event.x() - self._previous_pos.x()) - zoom = 0.1 if pos_x > 0 else -0.1 - self._set_viewer_zoom(zoom, 0.05, pos=event.pos()) - elif self.MMB_state or (self.LMB_state and self.ALT_state): - previous_pos = self.mapToScene(self._previous_pos) - current_pos = self.mapToScene(event.pos()) - delta = previous_pos - current_pos - self._set_viewer_pan(delta.x(), delta.y()) - - if not self.ALT_state: - if self.SHIFT_state or self.CTRL_state: - if not self._LIVE_PIPE.isVisible(): - self._cursor_text.setPos(self.mapToScene(event.pos())) - - if self.LMB_state and self._rubber_band.isActive: - rect = QtCore.QRect(self._origin_pos, event.pos()).normalized() - # if the rubber band is too small, do not show it. - if max(rect.width(), rect.height()) > 5: - if not self._rubber_band.isVisible(): - self._rubber_band.show() - map_rect = self.mapToScene(rect).boundingRect() - path = QtGui.QPainterPath() - path.addRect(map_rect) - self._rubber_band.setGeometry(rect) - self.scene().setSelectionArea( - path, mode=QtCore.Qt.ItemSelectionMode.IntersectsItemShape - ) - self.scene().update(map_rect) - - if self.SHIFT_state or self.CTRL_state: - nodes, pipes = self.selected_items() - - for node in self._prev_selection_nodes: - node.selected = True - - if self.CTRL_state: - for pipe in pipes: - pipe.setSelected(False) - for node in nodes: - node.selected = False - - elif self.LMB_state: - self.COLLIDING_state = False - nodes, pipes = self.selected_items() - if len(nodes) == 1: - node = nodes[0] - [p.setSelected(False) for p in pipes] - - if self.pipe_collision: - colliding_pipes = [ - i for i in node.collidingItems() - if isinstance(i, PipeItem) and i.isVisible() - ] - for pipe in colliding_pipes: - if not pipe.input_port: - continue - port_node_check = all([ - not pipe.input_port.node is node, - not pipe.output_port.node is node - ]) - if port_node_check: - pipe.setSelected(True) - self.COLLIDING_state = True - break - - self._previous_pos = event.pos() - super(NodeViewer, self).mouseMoveEvent(event) - - def wheelEvent(self, event): - try: - delta = event.delta() - except AttributeError: - # For PyQt5 - delta = event.angleDelta().y() - if delta == 0: - delta = event.angleDelta().x() - self._set_viewer_zoom(delta, pos=event.pos()) - - def dropEvent(self, event): - pos = self.mapToScene(event.pos()) - event.setDropAction(QtCore.Qt.DropAction.CopyAction) - self.data_dropped.emit( - event.mimeData(), QtCore.QPointF(pos.x(), pos.y()) - ) - - def dragEnterEvent(self, event): - is_acceptable = any([ - event.mimeData().hasFormat(i) for i in - ['nodegraphqt/nodes', 'text/plain', 'text/uri-list'] - ]) - if is_acceptable: - event.accept() - else: - event.ignore() - - def dragMoveEvent(self, event): - is_acceptable = any([ - event.mimeData().hasFormat(i) for i in - ['nodegraphqt/nodes', 'text/plain', 'text/uri-list'] - ]) - if is_acceptable: - event.accept() - else: - event.ignore() - - def dragLeaveEvent(self, event): - event.ignore() - - def keyPressEvent(self, event): - """ - Key press event re-implemented to update the states for attributes: - - ALT_state - - CTRL_state - - SHIFT_state - - Args: - event (QtGui.QKeyEvent): key event. - """ - self.ALT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier - self.CTRL_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier - self.SHIFT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier - - # Todo: find a better solution to catch modifier keys. - if event.modifiers() == (QtCore.Qt.KeyboardModifier.AltModifier | QtCore.Qt.KeyboardModifier.ShiftModifier): - self.ALT_state = True - self.SHIFT_state = True - - if self._LIVE_PIPE.isVisible(): - super(NodeViewer, self).keyPressEvent(event) - return - - # show cursor text - overlay_text = None - self._cursor_text.setVisible(False) - if not self.ALT_state: - if self.SHIFT_state: - overlay_text = '\n SHIFT:\n Toggle/Extend Selection' - elif self.CTRL_state: - overlay_text = '\n CTRL:\n Deselect Nodes' - elif self.ALT_state and self.SHIFT_state: - if self.pipe_slicing: - overlay_text = '\n ALT + SHIFT:\n Pipe Slicer Enabled' - if overlay_text: - self._cursor_text.setPlainText(overlay_text) - self._cursor_text.setPos(self.mapToScene(self._previous_pos)) - self._cursor_text.setVisible(True) - - super(NodeViewer, self).keyPressEvent(event) - - def keyReleaseEvent(self, event): - """ - Key release event re-implemented to update the states for attributes: - - ALT_state - - CTRL_state - - SHIFT_state - - Args: - event (QtGui.QKeyEvent): key event. - """ - self.ALT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier - self.CTRL_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier - self.SHIFT_state = event.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier - super(NodeViewer, self).keyReleaseEvent(event) - - # hide and reset cursor text. - self._cursor_text.setPlainText('') - self._cursor_text.setVisible(False) - - # --- scene events --- - - def sceneMouseMoveEvent(self, event): - """ - triggered mouse move event for the scene. - - redraw the live connection pipe. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): - The event handler from the QtWidgets.QGraphicsScene - """ - if not self._LIVE_PIPE.isVisible(): - return - if not self._start_port: - return - - pos = event.scenePos() - pointer_color = None - for item in self.scene().items(pos): - if not isinstance(item, PortItem): - continue - - x = item.boundingRect().width() / 2 - y = item.boundingRect().height() / 2 - pos = item.scenePos() - pos.setX(pos.x() + x) - pos.setY(pos.y() + y) - if item == self._start_port: - break - pointer_color = PipeEnum.HIGHLIGHT_COLOR.value - accept = self._validate_accept_connection(self._start_port, item) - if not accept: - pointer_color = [150, 60, 255] - break - reject = self._validate_reject_connection(self._start_port, item) - if reject: - pointer_color = [150, 60, 255] - break - - if self.acyclic: - if item.node == self._start_port.node: - pointer_color = PipeEnum.DISABLED_COLOR.value - elif item.port_type == self._start_port.port_type: - pointer_color = PipeEnum.DISABLED_COLOR.value - break - - self._LIVE_PIPE.draw_path( - self._start_port, cursor_pos=pos, color=pointer_color - ) - - def sceneMousePressEvent(self, event): - """ - triggered mouse press event for the scene (takes priority over viewer event). - - detect selected pipe and start connection. - - remap Shift and Ctrl modifier. - - Args: - event (QtWidgets.QGraphicsScenePressEvent): - The event handler from the QtWidgets.QGraphicsScene - """ - # pipe slicer enabled. - if self.ALT_state and self.SHIFT_state: - return - - # viewer pan mode. - if self.ALT_state: - return - - if self._LIVE_PIPE.isVisible(): - self.apply_live_connection(event) - return - - pos = event.scenePos() - items = self._items_near(pos, None, 5, 5) - - # filter from the selection stack in the following order - # "node, port, pipe" this is to avoid selecting items under items. - node, port, pipe = None, None, None - for item in items: - if isinstance(item, AbstractNodeItem): - node = item - elif isinstance(item, PortItem): - port = item - elif isinstance(item, PipeItem): - pipe = item - if any([node, port, pipe]): - break - - if port: - if port.locked: - return - - if not port.multi_connection and port.connected_ports: - self._detached_port = port.connected_ports[0] - self.start_live_connection(port) - if not port.multi_connection: - [p.delete() for p in port.connected_pipes] - return - - if node: - node_items = self._items_near(pos, AbstractNodeItem, 3, 3) - - # record the node positions at selection time. - for n in node_items: - self._node_positions[n] = n.xy_pos - - # emit selected node id with LMB. - if event.button() == QtCore.Qt.MouseButton.LeftButton: - self.node_selected.emit(node.id) - - if not isinstance(node, BackdropNodeItem): - return - - if pipe: - if not self.LMB_state: - return - - from_port = pipe.port_from_pos(pos, True) - - if from_port.locked: - return - - from_port.hovered = True - - attr = { - PortTypeEnum.IN.value: 'output_port', - PortTypeEnum.OUT.value: 'input_port' - } - self._detached_port = getattr(pipe, attr[from_port.port_type]) - self.start_live_connection(from_port) - self._LIVE_PIPE.draw_path(self._start_port, cursor_pos=pos) - - if self.SHIFT_state: - self._LIVE_PIPE.shift_selected = True - return - - pipe.delete() - - def sceneMouseReleaseEvent(self, event): - """ - triggered mouse release event for the scene. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): - The event handler from the QtWidgets.QGraphicsScene - """ - if event.button() != QtCore.Qt.MouseButton.MiddleButton: - self.apply_live_connection(event) - - # --- port connections --- - - def _validate_accept_connection(self, from_port, to_port): - """ - Check if a pipe connection is allowed if there are a constraints set - on the ports. - - Args: - from_port (PortItem): - to_port (PortItem): - - Returns: - bool: true to allow connection. - """ - accept_validation = [] - - to_ptype = to_port.port_type - from_ptype = from_port.port_type - - # validate the start. - from_data = self.accept_connection_types.get(from_port.node.type_) or {} - constraints = from_data.get(from_ptype, {}).get(from_port.name, {}) - accept_data = constraints.get(to_port.node.type_, {}) - accepted_pnames = accept_data.get(to_ptype, {}) - if constraints: - if to_port.name in accepted_pnames: - accept_validation.append(True) - else: - accept_validation.append(False) - - # validate the end. - to_data = self.accept_connection_types.get(to_port.node.type_) or {} - constraints = to_data.get(to_ptype, {}).get(to_port.name, {}) - accept_data = constraints.get(from_port.node.type_, {}) - accepted_pnames = accept_data.get(from_ptype, {}) - if constraints: - if from_port.name in accepted_pnames: - accept_validation.append(True) - else: - accept_validation.append(False) - - if False in accept_validation: - return False - return True - - def _validate_reject_connection(self, from_port, to_port): - """ - Check if a pipe connection is NOT allowed if there are a constrains set - on the ports. - - Args: - from_port (PortItem): - to_port (PortItem): - - Returns: - bool: true to reject connection. - """ - to_ptype = to_port.port_type - from_ptype = from_port.port_type - - to_data = self.reject_connection_types.get(to_port.node.type_) or {} - constraints = to_data.get(to_ptype, {}).get(to_port.name, {}) - reject_data = constraints.get(from_port.node.type_, {}) - - rejected_pnames = reject_data.get(from_ptype) - if rejected_pnames: - if from_port.name in rejected_pnames: - return True - return False - - from_data = self.reject_connection_types.get(from_port.node.type_) or {} - constraints = from_data.get(from_ptype, {}).get(from_port.name, {}) - reject_data = constraints.get(to_port.node.type_, {}) - - rejected_pnames = reject_data.get(to_ptype) - if rejected_pnames: - if to_port.name in rejected_pnames: - return True - return False - return False - - def apply_live_connection(self, event): - """ - triggered mouse press/release event for the scene. - - verifies the live connection pipe. - - makes a connection pipe if valid. - - emits the "connection changed" signal. - - Args: - event (QtWidgets.QGraphicsSceneMouseEvent): - The event handler from the QtWidgets.QGraphicsScene - """ - if not self._LIVE_PIPE.isVisible(): - return - - self._start_port.hovered = False - - # find the end port. - end_port = None - for item in self.scene().items(event.scenePos()): - if isinstance(item, PortItem): - end_port = item - break - - connected = [] - disconnected = [] - - # if port disconnected from existing pipe. - if end_port is None: - if self._detached_port and not self._LIVE_PIPE.shift_selected: - dist = math.hypot(self._previous_pos.x() - self._origin_pos.x(), - self._previous_pos.y() - self._origin_pos.y()) - if dist <= 2.0: # cursor pos threshold. - self.establish_connection(self._start_port, - self._detached_port) - self._detached_port = None - else: - disconnected.append((self._start_port, self._detached_port)) - self.connection_changed.emit(disconnected, connected) - - self._detached_port = None - self.end_live_connection() - return - - else: - if self._start_port is end_port: - return - - # if connection to itself - same_node_connection = end_port.node == self._start_port.node - if not self.acyclic: - # allow a node cycle connection. - same_node_connection = False - - # constrain check - accept_connection = self._validate_accept_connection( - self._start_port, end_port - ) - reject_connection = self._validate_reject_connection( - self._start_port, end_port - ) - - # restore connection check. - restore_connection = any([ - # if the end port is locked. - end_port.locked, - # if same port type. - end_port.port_type == self._start_port.port_type, - # if connection to itself. - same_node_connection, - # if end port is the start port. - end_port == self._start_port, - # if detached port is the end port. - self._detached_port == end_port, - # if a port has a accept port type constrain. - not accept_connection, - # if a port has a reject port type constrain. - reject_connection - ]) - if restore_connection: - if self._detached_port: - to_port = self._detached_port or end_port - self.establish_connection(self._start_port, to_port) - self._detached_port = None - self.end_live_connection() - return - - # end connection if starting port is already connected. - if self._start_port.multi_connection and \ - self._start_port in end_port.connected_ports: - self._detached_port = None - self.end_live_connection() - return - - # register as disconnected if not acyclic. - if self.acyclic and not self.acyclic_check(self._start_port, end_port): - if self._detached_port: - disconnected.append((self._start_port, self._detached_port)) - - self.connection_changed.emit(disconnected, connected) - - self._detached_port = None - self.end_live_connection() - return - - # make connection. - if not end_port.multi_connection and end_port.connected_ports: - dettached_end = end_port.connected_ports[0] - disconnected.append((end_port, dettached_end)) - - if self._detached_port: - disconnected.append((self._start_port, self._detached_port)) - - connected.append((self._start_port, end_port)) - - self.connection_changed.emit(disconnected, connected) - - self._detached_port = None - self.end_live_connection() - - def start_live_connection(self, selected_port): - """ - create new pipe for the connection. - (show the live pipe visibility from the port following the cursor position) - """ - if not selected_port: - return - self._start_port = selected_port - if self._start_port.type == PortTypeEnum.IN.value: - self._LIVE_PIPE.input_port = self._start_port - elif self._start_port == PortTypeEnum.OUT.value: - self._LIVE_PIPE.output_port = self._start_port - self._LIVE_PIPE.setVisible(True) - self._LIVE_PIPE.draw_index_pointer( - selected_port, - self.mapToScene(self._origin_pos) - ) - - def end_live_connection(self): - """ - delete live connection pipe and reset start port. - (hides the pipe item used for drawing the live connection) - """ - self._LIVE_PIPE.reset_path() - self._LIVE_PIPE.setVisible(False) - self._LIVE_PIPE.shift_selected = False - self._start_port = None - - def establish_connection(self, start_port, end_port): - """ - establish a new pipe connection. - (adds a new pipe item to draw between 2 ports) - """ - pipe = PipeItem() - self.scene().addItem(pipe) - pipe.set_connections(start_port, end_port) - pipe.draw_path(pipe.input_port, pipe.output_port) - if start_port.node.selected or end_port.node.selected: - pipe.highlight() - if not start_port.node.visible or not end_port.node.visible: - pipe.hide() - - @staticmethod - def acyclic_check(start_port, end_port): - """ - Validate the node connections, so it doesn't loop itself. - - Args: - start_port (PortItem): port item. - end_port (PortItem): port item. - - Returns: - bool: True if port connection is valid. - """ - start_node = start_port.node - check_nodes = [end_port.node] - io_types = { - PortTypeEnum.IN.value: 'outputs', - PortTypeEnum.OUT.value: 'inputs' - } - while check_nodes: - check_node = check_nodes.pop(0) - for check_port in getattr(check_node, io_types[end_port.port_type]): - if check_port.connected_ports: - for port in check_port.connected_ports: - if port.node != start_node: - check_nodes.append(port.node) - else: - return False - return True - - # --- viewer --- - - def tab_search_set_nodes(self, nodes): - self._search_widget.set_nodes(nodes) - - def tab_search_toggle(self): - state = self._search_widget.isVisible() - if not state: - self._search_widget.setVisible(state) - self.setFocus() - return - - pos = self._previous_pos - rect = self._search_widget.rect() - new_pos = QtCore.QPoint(int(pos.x() - rect.width() / 2), - int(pos.y() - rect.height() / 2)) - self._search_widget.move(new_pos) - self._search_widget.setVisible(state) - self._search_widget.setFocus() - - rect = self.mapToScene(rect).boundingRect() - self.scene().update(rect) - - def rebuild_tab_search(self): - if isinstance(self._search_widget, TabSearchMenuWidget): - self._search_widget.rebuild = True - - def qaction_for_undo(self): - """ - Get the undo QAction from the parent undo stack. - - Returns: - QtWidgets.QAction: undo action. - """ - return self._undo_action - - def qaction_for_redo(self): - """ - Get the redo QAction from the parent undo stack. - - Returns: - QtWidgets.QAction: redo action. - """ - return self._redo_action - - def context_menus(self): - """ - All the available context menus for the viewer. - - Returns: - dict: viewer context menu. - """ - return {'graph': self._ctx_graph_menu, 'nodes': self._ctx_node_menu} - - def question_dialog(self, text, title='Node Graph', dialog_icon=None, - custom_icon=None, parent=None): - """ - Prompt node viewer question dialog widget with "yes", "no" buttons. - - Args: - text (str): dialog text. - title (str): dialog window title. - dialog_icon (str): display icon. ("information", "warning", "critical") - custom_icon (str): custom icon to display. - parent (QtWidgets.QObject): override dialog parent. (optional) - - Returns: - bool: true if user click yes. - """ - parent = parent or self - - self.clear_key_state() - return BaseDialog.question_dialog( - parent, text, title, dialog_icon, custom_icon - ) - - def message_dialog(self, text, title='Node Graph', dialog_icon=None, - custom_icon=None, parent=None): - """ - Prompt node viewer message dialog widget with "ok" button. - - Args: - text (str): dialog text. - title (str): dialog window title. - dialog_icon (str): display icon. ("information", "warning", "critical") - custom_icon (str): custom icon to display. - parent (QtWidgets.QObject): override dialog parent. (optional) - """ - parent = parent or self - - self.clear_key_state() - BaseDialog.message_dialog(parent, text, title, dialog_icon, custom_icon) - - def load_dialog(self, current_dir=None, ext=None, parent=None): - """ - Prompt node viewer file load dialog widget. - - Args: - current_dir (str): directory path starting point. (optional) - ext (str): custom file extension filter type. (optional) - parent (QtWidgets.QObject): override dialog parent. (optional) - - Returns: - str: selected file path. - """ - parent = parent or self - - self.clear_key_state() - ext = '*{} '.format(ext) if ext else '' - ext_filter = ';;'.join([ - 'Node Graph ({}*json)'.format(ext), 'All Files (*)' - ]) - file_dlg = FileDialog.getOpenFileName( - parent, 'Open File', current_dir, ext_filter) - file = file_dlg[0] or None - return file - - def save_dialog(self, current_dir=None, ext=None, parent=None): - """ - Prompt node viewer file save dialog widget. - - Args: - current_dir (str): directory path starting point. (optional) - ext (str): custom file extension filter type. (optional) - parent (QtWidgets.QObject): override dialog parent. (optional) - - Returns: - str: selected file path. - """ - parent = parent or self - - self.clear_key_state() - ext_label = '*{} '.format(ext) if ext else '' - ext_type = '.{}'.format(ext) if ext else '.json' - ext_map = {'Node Graph ({}*json)'.format(ext_label): ext_type, - 'All Files (*)': ''} - file_dlg = FileDialog.getSaveFileName( - parent, 'Save Session', current_dir, ';;'.join(ext_map.keys())) - file_path = file_dlg[0] - if not file_path: - return - ext = ext_map[file_dlg[1]] - if ext and not file_path.endswith(ext): - file_path += ext - - return file_path - - def all_pipes(self): - """ - Returns all pipe qgraphic items. - - Returns: - list[PipeItem]: instances of pipe items. - """ - excl = [self._LIVE_PIPE, self._SLICER_PIPE] - return [i for i in self.scene().items() - if isinstance(i, PipeItem) and i not in excl] - - def all_nodes(self): - """ - Returns all node qgraphic items. - - Returns: - list[AbstractNodeItem]: instances of node items. - """ - return [i for i in self.scene().items() - if isinstance(i, AbstractNodeItem)] - - def selected_nodes(self): - """ - Returns selected node qgraphic items. - - Returns: - list[AbstractNodeItem]: instances of node items. - """ - return [i for i in self.scene().selectedItems() - if isinstance(i, AbstractNodeItem)] - - def selected_pipes(self): - """ - Returns selected pipe qgraphic items. - - Returns: - list[Pipe]: pipe items. - """ - pipes = [i for i in self.scene().selectedItems() - if isinstance(i, PipeItem)] - return pipes - - def selected_items(self): - """ - Return selected graphic items in the scene. - - Returns: - tuple(list[AbstractNodeItem], list[Pipe]): - selected (node items, pipe items). - """ - nodes = [] - pipes = [] - for item in self.scene().selectedItems(): - if isinstance(item, AbstractNodeItem): - nodes.append(item) - elif isinstance(item, PipeItem): - pipes.append(item) - return nodes, pipes - - def add_node(self, node, pos=None): - """ - Add node item into the scene. - - Args: - node (AbstractNodeItem): node item instance. - pos (tuple or list): node scene position. - """ - pos = pos or (self._previous_pos.x(), self._previous_pos.y()) - node.pre_init(self, pos) - self.scene().addItem(node) - node.post_init(self, pos) - - @staticmethod - def remove_node(node): - """ - Remove node item from the scene. - - Args: - node (AbstractNodeItem): node item instance. - """ - if isinstance(node, AbstractNodeItem): - node.delete() - - def move_nodes(self, nodes, pos=None, offset=None): - """ - Globally move specified nodes. - - Args: - nodes (list[AbstractNodeItem]): node items. - pos (tuple or list): custom x, y position. - offset (tuple or list): x, y position offset. - """ - group = self.scene().createItemGroup(nodes) - group_rect = group.boundingRect() - if pos: - x, y = pos - else: - pos = self.mapToScene(self._previous_pos) - x = pos.x() - group_rect.center().x() - y = pos.y() - group_rect.center().y() - if offset: - x += offset[0] - y += offset[1] - group.setPos(x, y) - self.scene().destroyItemGroup(group) - - def get_pipes_from_nodes(self, nodes=None): - nodes = nodes or self.selected_nodes() - if not nodes: - return - pipes = [] - for node in nodes: - n_inputs = node.inputs if hasattr(node, 'inputs') else [] - n_outputs = node.outputs if hasattr(node, 'outputs') else [] - - for port in n_inputs: - for pipe in port.connected_pipes: - connected_node = pipe.output_port.node - if connected_node in nodes: - pipes.append(pipe) - for port in n_outputs: - for pipe in port.connected_pipes: - connected_node = pipe.input_port.node - if connected_node in nodes: - pipes.append(pipe) - return pipes - - def center_selection(self, nodes=None): - """ - Center on the given nodes or all nodes by default. - - Args: - nodes (list[AbstractNodeItem]): a list of node items. - """ - if not nodes: - if self.selected_nodes(): - nodes = self.selected_nodes() - elif self.all_nodes(): - nodes = self.all_nodes() - if not nodes: - return - - rect = self._combined_rect(nodes) - self._scene_range.translate(rect.center() - self._scene_range.center()) - self.setSceneRect(self._scene_range) - - def get_pipe_layout(self): - """ - Returns the pipe layout mode. - - Returns: - int: pipe layout mode. - """ - return self._pipe_layout - - def set_pipe_layout(self, layout): - """ - Sets the pipe layout mode and redraw all pipe items in the scene. - - Args: - layout (int): pipe layout mode. (see the constants module) - """ - self._pipe_layout = layout - for pipe in self.all_pipes(): - pipe.draw_path(pipe.input_port, pipe.output_port) - - def get_layout_direction(self): - """ - Returns the layout direction set on the node graph viewer - used by the pipe items for drawing. - - Returns: - int: graph layout mode. - """ - return self._layout_direction - - def set_layout_direction(self, direction): - """ - Sets the node graph viewer layout direction for re-drawing - the pipe items. - - Args: - direction (int): graph layout direction. - """ - self._layout_direction = direction - for pipe_item in self.all_pipes(): - pipe_item.draw_path(pipe_item.input_port, pipe_item.output_port) - - def reset_zoom(self, cent=None): - """ - Reset the viewer zoom level. - - Args: - cent (QtCore.QPoint): specified center. - """ - self._scene_range = QtCore.QRectF(0, 0, - self.size().width(), - self.size().height()) - if cent: - self._scene_range.translate(cent - self._scene_range.center()) - self._update_scene() - - def get_zoom(self): - """ - Returns the viewer zoom level. - - Returns: - float: zoom level. - """ - transform = self.transform() - cur_scale = (transform.m11(), transform.m22()) - return float('{:0.2f}'.format(cur_scale[0] - 1.0)) - - def set_zoom(self, value=0.0): - """ - Set the viewer zoom level. - - Args: - value (float): zoom level - """ - if value == 0.0: - self.reset_zoom() - return - zoom = self.get_zoom() - if zoom < 0.0: - if not (ZOOM_MIN <= zoom <= ZOOM_MAX): - return - else: - if not (ZOOM_MIN <= value <= ZOOM_MAX): - return - value = value - zoom - self._set_viewer_zoom(value, 0.0) - - def zoom_to_nodes(self, nodes): - self._scene_range = self._combined_rect(nodes) - self._update_scene() - - if self.get_zoom() > 0.1: - self.reset_zoom(self._scene_range.center()) - - def force_update(self): - """ - Redraw the current node graph scene. - """ - self._update_scene() - - def scene_rect(self): - """ - Returns the scene rect size. - - Returns: - list[float]: x, y, width, height - """ - return [self._scene_range.x(), self._scene_range.y(), - self._scene_range.width(), self._scene_range.height()] - - def set_scene_rect(self, rect): - """ - Sets the scene rect and redraws the scene. - - Args: - rect (list[float]): x, y, width, height - """ - self._scene_range = QtCore.QRectF(*rect) - self._update_scene() - - def scene_center(self): - """ - Get the center x,y pos from the scene. - - Returns: - list[float]: x, y position. - """ - cent = self._scene_range.center() - return [cent.x(), cent.y()] - - def scene_cursor_pos(self): - """ - Returns the cursor last position mapped to the scene. - - Returns: - QtCore.QPoint: cursor position. - """ - return self.mapToScene(self._previous_pos) - - def nodes_rect_center(self, nodes): - """ - Get the center x,y pos from the specified nodes. - - Args: - nodes (list[AbstractNodeItem]): list of node qgrphics items. - - Returns: - list[float]: x, y position. - """ - cent = self._combined_rect(nodes).center() - return [cent.x(), cent.y()] - - def clear_key_state(self): - """ - Resets the Ctrl, Shift, Alt modifiers key states. - """ - self.CTRL_state = False - self.SHIFT_state = False - self.ALT_state = False - - def use_OpenGL(self): - """ - Use QOpenGLWidget as the viewer. - """ - # use QOpenGLWidget instead of the deprecated QGLWidget to avoid - # problems with Wayland. - - # TODO: Review this part and make sure we do not break anything - # import qtpy - # if qtpy.PYSIDE2: - # from PySide2.QtWidgets import QOpenGLWidget - # elif qtpy.PYQT5: - # from PyQt5.QtWidgets import QOpenGLWidget - # elif qtpy.PYSIDE6: - # from PySide6.QtOpenGLWidgets import QOpenGLWidget - # elif qtpy.PYQT6: - # from PyQt6.QtOpenGLWidgets import QOpenGLWidget - - self.setViewport(QtWidgets.QOpenGLWidget()) diff --git a/cuegui/NodeGraphQt/widgets/viewer_nav.py b/cuegui/NodeGraphQt/widgets/viewer_nav.py deleted file mode 100644 index 23fe60c6f..000000000 --- a/cuegui/NodeGraphQt/widgets/viewer_nav.py +++ /dev/null @@ -1,198 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui - -from NodeGraphQt.constants import NodeEnum, ViewerNavEnum - - -class NodeNavigationDelagate(QtWidgets.QStyledItemDelegate): - - def paint(self, painter, option, index): - """ - Args: - painter (QtGui.QPainter): - option (QtGui.QStyleOptionViewItem): - index (QtCore.QModelIndex): - """ - if index.column() != 0: - super(NodeNavigationDelagate, self).paint(painter, option, index) - return - - item = index.model().item(index.row(), index.column()) - - margin = 1.0, 1.0 - rect = QtCore.QRectF( - option.rect.x() + margin[0], - option.rect.y() + margin[1], - option.rect.width() - (margin[0] * 2), - option.rect.height() - (margin[1] * 2) - ) - - painter.save() - painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) - - # background. - bg_color = QtGui.QColor(*ViewerNavEnum.ITEM_COLOR.value) - itm_color = QtGui.QColor(80, 128, 123) - if option.state & QtWidgets.QStyle.StateFlag.State_Selected: - bg_color = bg_color.lighter(120) - itm_color = QtGui.QColor(*NodeEnum.SELECTED_BORDER_COLOR.value) - - roundness = 2.0 - painter.setBrush(bg_color) - painter.drawRoundedRect(rect, roundness, roundness) - - if index.row() != 0: - txt_offset = 8.0 - m = 6.0 - x = rect.left() + 2.0 + m - y = rect.top() + m + 2 - h = rect.height() - (m * 2) - 2 - painter.setBrush(itm_color) - for i in range(4): - itm_rect = QtCore.QRectF(x, y, 1.3, h) - painter.drawRoundedRect(itm_rect, 1.0, 1.0) - x += 2.0 - y += 2 - h -= 4 - else: - txt_offset = 5.0 - x = rect.left() + 4.0 - size = 10.0 - for clr in [QtGui.QColor(0, 0, 0, 80), itm_color]: - itm_rect = QtCore.QRectF( - x, rect.center().y() - (size / 2), size, size) - painter.setBrush(clr) - painter.drawRoundedRect(itm_rect, 2.0, 2.0) - size -= 5.0 - x += 2.5 - - # text - # pen_color = option.palette.text().color() - pen_color = QtGui.QColor(*tuple(map( - lambda i, j: i - j, (255, 255, 255), bg_color.getRgb() - ))) - pen = QtGui.QPen(pen_color, 0.5) - pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - - font = painter.font() - font_metrics = QtGui.QFontMetrics(font) - item_text = item.text().replace(' ', '_') - if hasattr(font_metrics, 'horizontalAdvance'): - font_width = font_metrics.horizontalAdvance(item_text) - else: - font_width = font_metrics.width(item_text) - font_height = font_metrics.height() - text_rect = QtCore.QRectF( - rect.center().x() - (font_width / 2) + txt_offset, - rect.center().y() - (font_height / 2), - font_width, font_height - ) - painter.drawText(text_rect, item.text()) - painter.restore() - - -class NodeNavigationWidget(QtWidgets.QListView): - - navigation_changed = QtCore.Signal(str, list) - - def __init__(self, parent=None): - super(NodeNavigationWidget, self).__init__(parent) - self.setSelectionMode(self.SelectionMode.SingleSelection) - self.setResizeMode(self.ResizeMode.Adjust) - self.setViewMode(self.ViewMode.ListMode) - self.setFlow(self.Flow.LeftToRight) - self.setDragEnabled(False) - self.setMinimumHeight(20) - self.setMaximumHeight(36) - self.setSpacing(0) - - # self.viewport().setAutoFillBackground(False) - self.setStyleSheet( - 'QListView {{border: 0px;background-color: rgb({0},{1},{2});}}' - .format(*ViewerNavEnum.BACKGROUND_COLOR.value) - ) - - self.setItemDelegate(NodeNavigationDelagate(self)) - self.setModel(QtGui.QStandardItemModel()) - - def keyPressEvent(self, event): - event.ignore() - - def mouseReleaseEvent(self, event): - super(NodeNavigationWidget, self).mouseReleaseEvent(event) - if not self.selectedIndexes(): - return - index = self.selectedIndexes()[0] - rows = reversed(range(1, self.model().rowCount())) - if index.row() == 0: - rows = [r for r in rows if r > 0] - else: - rows = [r for r in rows if index.row() < r] - if not rows: - return - rm_node_ids = [self.model().item(r, 0).toolTip() for r in rows] - node_id = self.model().item(index.row(), 0).toolTip() - [self.model().removeRow(r) for r in rows] - self.navigation_changed.emit(node_id, rm_node_ids) - - def clear(self): - self.model().sourceMode().clear() - - def add_label_item(self, label, node_id): - item = QtGui.QStandardItem(label) - item.setToolTip(node_id) - metrics = QtGui.QFontMetrics(item.font()) - if hasattr(metrics, 'horizontalAdvance'): - width = metrics.horizontalAdvance(item.text()) - else: - width = metrics.width(item.text()) - width *= 1.5 - item.setSizeHint(QtCore.QSize(int(width), 20)) - self.model().appendRow(item) - self.selectionModel().setCurrentIndex( - self.model().indexFromItem(item), - QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect) - - def update_label_item(self, label, node_id): - rows = reversed(range(self.model().rowCount())) - for r in rows: - item = self.model().item(r, 0) - if item.toolTip() == node_id: - item.setText(label) - - def remove_label_item(self, node_id): - rows = reversed(range(1, self.model().rowCount())) - node_ids = [self.model().item(r, 0).toolTip() for r in rows] - if node_id not in node_ids: - return - index = node_ids.index(node_id) - if index == 0: - rows = [r for r in rows if r > 0] - else: - rows = [r for r in rows if index < r] - [self.model().removeRow(r) for r in rows] - - -if __name__ == '__main__': - import sys - - def on_nav_changed(selected_id, remove_ids): - print(selected_id, remove_ids) - - app = QtWidgets.QApplication(sys.argv) - - widget = NodeNavigationWidget() - widget.navigation_changed.connect(on_nav_changed) - - widget.add_label_item('Close Graph', 'root') - for i in range(1, 5): - widget.add_label_item( - 'group node {}'.format(i), - 'node_id{}'.format(i) - ) - widget.resize(600, 30) - widget.show() - - app.exec_() diff --git a/external/NodeGraphQt b/external/NodeGraphQt new file mode 160000 index 000000000..4d4a17e5a --- /dev/null +++ b/external/NodeGraphQt @@ -0,0 +1 @@ +Subproject commit 4d4a17e5a88a82ec436696e6c266f1c8918124d7 From 2922a201ae7838498f37904aa56f4da391a1cdc7 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Wed, 11 Sep 2024 22:21:21 +0200 Subject: [PATCH 17/26] Small change to maintain support for PySide2 --- external/NodeGraphQt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/NodeGraphQt b/external/NodeGraphQt index 4d4a17e5a..68859a523 160000 --- a/external/NodeGraphQt +++ b/external/NodeGraphQt @@ -1 +1 @@ -Subproject commit 4d4a17e5a88a82ec436696e6c266f1c8918124d7 +Subproject commit 68859a523ec22b74c7e4da231eedffeecc733582 From 7c6c159ab027d78a0ac21452a747e800dba8740e Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Wed, 11 Sep 2024 22:40:31 +0200 Subject: [PATCH 18/26] Small change to maintain support for PySide2 --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index f3d3251bc..b2fa30db7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "external/NodeGraphQt"] path = external/NodeGraphQt url = https://github.com/lithorus/NodeGraphQt.git - branch = 4d4a17e5a88a82ec436696e6c266f1c8918124d7 + branch = pyside2+6 From d7f190e00ee50c22e4919cd227780ffb40b0c4a7 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Wed, 11 Sep 2024 22:49:53 +0200 Subject: [PATCH 19/26] Test with changes to CI/CD --- .github/workflows/testing-pipeline.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/testing-pipeline.yml b/.github/workflows/testing-pipeline.yml index bcbbb7706..147fb3a96 100644 --- a/.github/workflows/testing-pipeline.yml +++ b/.github/workflows/testing-pipeline.yml @@ -15,6 +15,8 @@ jobs: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Run Python Tests run: ci/run_python_tests.sh --no-gui @@ -38,6 +40,8 @@ jobs: container: aswf/ci-opencue:2023 steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Run Python Tests run: ci/run_python_tests.sh @@ -60,6 +64,8 @@ jobs: steps: - uses: actions/checkout@v3 - name: Run Python Tests + with: + submodules: recursive run: ci/run_python_tests.sh test_cuebot_2024: @@ -80,6 +86,8 @@ jobs: container: almalinux:9 steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Run CueGUI Tests run: ci/test_pyside6.sh @@ -91,6 +99,8 @@ jobs: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true steps: - uses: actions/checkout@v3 + with: + submodules: true - name: Lint Python Code run: ci/run_python_lint.sh From a67eac0ed2a03be58d4429191e535899787191fe Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Wed, 11 Sep 2024 22:51:48 +0200 Subject: [PATCH 20/26] Small yaml structure fix --- .github/workflows/testing-pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing-pipeline.yml b/.github/workflows/testing-pipeline.yml index 147fb3a96..f263576dc 100644 --- a/.github/workflows/testing-pipeline.yml +++ b/.github/workflows/testing-pipeline.yml @@ -63,9 +63,9 @@ jobs: container: aswf/ci-opencue:2024 steps: - uses: actions/checkout@v3 - - name: Run Python Tests with: submodules: recursive + - name: Run Python Tests run: ci/run_python_tests.sh test_cuebot_2024: From 548790a5235fc89e138aa6e81e7d1a17ccc6d1f7 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Wed, 11 Sep 2024 22:55:47 +0200 Subject: [PATCH 21/26] Test with checkout v4 --- .github/workflows/testing-pipeline.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/testing-pipeline.yml b/.github/workflows/testing-pipeline.yml index f263576dc..a0379252f 100644 --- a/.github/workflows/testing-pipeline.yml +++ b/.github/workflows/testing-pipeline.yml @@ -14,7 +14,7 @@ jobs: env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - name: Run Python Tests @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest container: aswf/ci-opencue:2023 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - name: Run Python Tests @@ -62,7 +62,7 @@ jobs: runs-on: ubuntu-latest container: aswf/ci-opencue:2024 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - name: Run Python Tests @@ -85,7 +85,7 @@ jobs: runs-on: ubuntu-latest container: almalinux:9 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - name: Run CueGUI Tests @@ -98,7 +98,7 @@ jobs: env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: true - name: Lint Python Code From 36aee1df321171438044d77d50b8f9f2f5aaf4f7 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Thu, 12 Sep 2024 00:02:25 +0200 Subject: [PATCH 22/26] Remove PySide6 test and downgrade checkout in vfx 2022 tests --- .github/workflows/testing-pipeline.yml | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.github/workflows/testing-pipeline.yml b/.github/workflows/testing-pipeline.yml index a0379252f..9058925b8 100644 --- a/.github/workflows/testing-pipeline.yml +++ b/.github/workflows/testing-pipeline.yml @@ -14,9 +14,7 @@ jobs: env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true steps: - - uses: actions/checkout@v4 - with: - submodules: recursive + - uses: actions/checkout@v3 - name: Run Python Tests run: ci/run_python_tests.sh --no-gui @@ -80,17 +78,6 @@ jobs: chown -R aswfuser:aswfgroup . su -c "cd cuebot && ./gradlew build --stacktrace --info" aswfuser - test_pyside6: - name: Run CueGUI Tests using PySide6 - runs-on: ubuntu-latest - container: almalinux:9 - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - name: Run CueGUI Tests - run: ci/test_pyside6.sh - lint_python: name: Lint Python Code runs-on: ubuntu-latest From 9f7acabc24ec7d3f8c80610638e962cd9cf1092d Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Thu, 12 Sep 2024 00:03:15 +0200 Subject: [PATCH 23/26] Update lint python tests to vfx 2023 --- .github/workflows/testing-pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing-pipeline.yml b/.github/workflows/testing-pipeline.yml index 9058925b8..bafd55430 100644 --- a/.github/workflows/testing-pipeline.yml +++ b/.github/workflows/testing-pipeline.yml @@ -81,7 +81,7 @@ jobs: lint_python: name: Lint Python Code runs-on: ubuntu-latest - container: aswf/ci-opencue:2022 + container: aswf/ci-opencue:2023 env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true steps: From 3611402636bd2c06825592e9a5dd8eca439a3a85 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Thu, 12 Sep 2024 00:20:38 +0200 Subject: [PATCH 24/26] Delete pyside6 test script --- ci/test_pyside6.sh | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100755 ci/test_pyside6.sh diff --git a/ci/test_pyside6.sh b/ci/test_pyside6.sh deleted file mode 100755 index 05bd4c173..000000000 --- a/ci/test_pyside6.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# Script for testing CueGUI with PySide6. -# -# This script is written to be run within an almalinux environment in the OpenCue -# GitHub Actions environment. See .github/workflows/testing-pipeline.yml. - -set -e - -# Install needed packages. -yum -y install \ - dbus-libs \ - fontconfig \ - gcc \ - libxkbcommon-x11 \ - mesa-libEGL-devel \ - python-devel \ - which \ - xcb-util-keysyms \ - xcb-util-image \ - xcb-util-renderutil \ - xcb-util-wm \ - Xvfb - -# Install Python requirements. -python3 -m pip install --user -r requirements.txt -r requirements_gui.txt -# Replace PySide2 with PySide6. -python3 -m pip uninstall -y PySide2 -python3 -m pip install --user PySide6==6.3.2 - -# Fix compiled proto code for Python 3. -python3 -m grpc_tools.protoc -I=proto/ --python_out=pycue/opencue/compiled_proto --grpc_python_out=pycue/opencue/compiled_proto proto/*.proto -2to3 -wn -f import pycue/opencue/compiled_proto/*_pb2*.py - -# Run tests. -ci/run_gui_test.sh From 2a4b0d34ea453ff589f5afe47d86474156fd9902 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Tue, 3 Dec 2024 19:45:07 +0100 Subject: [PATCH 25/26] Remove external repo and add pypi package instead --- .gitmodules | 4 ---- cuegui/NodeGraphQt | 1 - cuegui/cuegui/AbstractGraphWidget.py | 4 ++-- cuegui/cuegui/nodegraph/__init__.py | 4 ++-- cuegui/cuegui/nodegraph/nodes/__init__.py | 2 +- cuegui/cuegui/nodegraph/nodes/base.py | 6 +++--- cuegui/cuegui/nodegraph/nodes/layer.py | 8 ++++---- cuegui/cuegui/nodegraph/widgets/nodeWidgets.py | 8 ++++---- external/NodeGraphQt | 1 - requirements_gui.txt | 1 + 10 files changed, 17 insertions(+), 22 deletions(-) delete mode 100644 .gitmodules delete mode 120000 cuegui/NodeGraphQt delete mode 160000 external/NodeGraphQt diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b2fa30db7..000000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "external/NodeGraphQt"] - path = external/NodeGraphQt - url = https://github.com/lithorus/NodeGraphQt.git - branch = pyside2+6 diff --git a/cuegui/NodeGraphQt b/cuegui/NodeGraphQt deleted file mode 120000 index be2a57d06..000000000 --- a/cuegui/NodeGraphQt +++ /dev/null @@ -1 +0,0 @@ -../external/NodeGraphQt/NodeGraphQt \ No newline at end of file diff --git a/cuegui/cuegui/AbstractGraphWidget.py b/cuegui/cuegui/AbstractGraphWidget.py index 74c8a66c4..8b4880644 100644 --- a/cuegui/cuegui/AbstractGraphWidget.py +++ b/cuegui/cuegui/AbstractGraphWidget.py @@ -18,8 +18,8 @@ from qtpy import QtCore from qtpy import QtWidgets -from NodeGraphQt import NodeGraph -from NodeGraphQt.errors import NodeRegistrationError +from NodeGraphQtPy import NodeGraph +from NodeGraphQtPy.errors import NodeRegistrationError from cuegui.nodegraph import CueLayerNode from cuegui import app diff --git a/cuegui/cuegui/nodegraph/__init__.py b/cuegui/cuegui/nodegraph/__init__.py index b36cfbc5f..625cfd879 100644 --- a/cuegui/cuegui/nodegraph/__init__.py +++ b/cuegui/cuegui/nodegraph/__init__.py @@ -13,9 +13,9 @@ # limitations under the License. -"""nodegraph is an OpenCue specific extension of NodeGraphQt +"""nodegraph is an OpenCue specific extension of NodeGraphQtPy -The docs for NodeGraphQt can be found at: +The docs for NodeGraphQtPy can be found at: http://chantasticvfx.com/nodeGraphQt/html/nodes.html """ from .nodes import CueLayerNode diff --git a/cuegui/cuegui/nodegraph/nodes/__init__.py b/cuegui/cuegui/nodegraph/nodes/__init__.py index 678c71e00..bd04f566e 100644 --- a/cuegui/cuegui/nodegraph/nodes/__init__.py +++ b/cuegui/cuegui/nodegraph/nodes/__init__.py @@ -13,7 +13,7 @@ # limitations under the License. -"""Module housing node implementations that work with NodeGraphQt""" +"""Module housing node implementations that work with NodeGraphQtPy""" from .layer import CueLayerNode diff --git a/cuegui/cuegui/nodegraph/nodes/base.py b/cuegui/cuegui/nodegraph/nodes/base.py index fe2596301..6665c6faf 100644 --- a/cuegui/cuegui/nodegraph/nodes/base.py +++ b/cuegui/cuegui/nodegraph/nodes/base.py @@ -13,16 +13,16 @@ # limitations under the License. -"""Base class for any cue nodes to work with NodeGraphQt""" +"""Base class for any cue nodes to work with NodeGraphQtPy""" from builtins import str -from NodeGraphQt import BaseNode +from NodeGraphQtPy import BaseNode from cuegui.nodegraph.widgets.nodeWidgets import NodeProgressBar class CueBaseNode(BaseNode): - """Base class for any cue nodes to work with NodeGraphQt""" + """Base class for any cue nodes to work with NodeGraphQtPy""" __identifier__ = "aswf.opencue" diff --git a/cuegui/cuegui/nodegraph/nodes/layer.py b/cuegui/cuegui/nodegraph/nodes/layer.py index e7080e869..7c9ec2f14 100644 --- a/cuegui/cuegui/nodegraph/nodes/layer.py +++ b/cuegui/cuegui/nodegraph/nodes/layer.py @@ -13,21 +13,21 @@ # limitations under the License. -"""Implementation of a Cue Layer node that works with NodeGraphQt""" +"""Implementation of a Cue Layer node that works with NodeGraphQtPy""" from __future__ import division import os from qtpy import QtGui import opencue -import NodeGraphQt.qgraphics.node_base +import NodeGraphQtPy.qgraphics.node_base import cuegui.images from cuegui.Constants import RGB_FRAME_STATE from cuegui.nodegraph.nodes.base import CueBaseNode class CueLayerNode(CueBaseNode): - """Implementation of a Cue Layer node that works with NodeGraphQt""" + """Implementation of a Cue Layer node that works with NodeGraphQtPy""" __identifier__ = "aswf.opencue" @@ -38,7 +38,7 @@ def __init__(self, layerRpcObject=None): self.set_name(layerRpcObject.name()) - NodeGraphQt.qgraphics.node_base.NODE_ICON_SIZE = 30 + NodeGraphQtPy.qgraphics.node_base.NODE_ICON_SIZE = 30 services = layerRpcObject.services() if services: app = services[0].name() diff --git a/cuegui/cuegui/nodegraph/widgets/nodeWidgets.py b/cuegui/cuegui/nodegraph/widgets/nodeWidgets.py index 69847bcfa..e881f0a27 100644 --- a/cuegui/cuegui/nodegraph/widgets/nodeWidgets.py +++ b/cuegui/cuegui/nodegraph/widgets/nodeWidgets.py @@ -15,7 +15,7 @@ """Module defining custom widgets that appear on nodes in the nodegraph. -The classes defined here inherit from NodeGraphQt base classes, therefore any +The classes defined here inherit from NodeGraphQtPy base classes, therefore any snake_case methods defined here are overriding the base class and must remain snake_case to work properly. """ @@ -23,7 +23,7 @@ from qtpy import QtWidgets from qtpy import QtCore -from NodeGraphQt.widgets.node_widgets import NodeBaseWidget +from NodeGraphQtPy.widgets.node_widgets import NodeBaseWidget class NodeProgressBar(NodeBaseWidget): @@ -90,7 +90,7 @@ def value(self): """Get value from progress bar on node XXX: This property shouldn't be required as it's been superseded by get_value, however the progress bar doesn't update without it. Believe it may be - a bug in NodeGraphQt's `NodeObject.set_property`. We should remove this + a bug in NodeGraphQtPy's `NodeObject.set_property`. We should remove this once it's been resolved. @return: progress bar value @rtype: int @@ -102,7 +102,7 @@ def value(self, value=0): """Set value on progress bar XXX: This property shouldn't be required as it's been superseded by set_value, however the progress bar doesn't update without it. Believe it may be - a bug in NodeGraphQt's `NodeObject.set_property`. We should remove this + a bug in NodeGraphQtPy's `NodeObject.set_property`. We should remove this once it's been resolved. @param value: Value to set on progress bar @type value: int diff --git a/external/NodeGraphQt b/external/NodeGraphQt deleted file mode 160000 index 68859a523..000000000 --- a/external/NodeGraphQt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 68859a523ec22b74c7e4da231eedffeecc733582 diff --git a/requirements_gui.txt b/requirements_gui.txt index eb5e544d4..11a37c939 100644 --- a/requirements_gui.txt +++ b/requirements_gui.txt @@ -2,3 +2,4 @@ PySide6==6.7.1;python_version>"3.11" PySide6==6.5.3;python_version=="3.11" PySide2==5.15.2.1;python_version<="3.10" QtPy==2.4.1 +NodeGraphQtPy==0.6.38.6 From 887333b8f171067c5c1cc88bd603325e55bbb26c Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Tue, 3 Dec 2024 20:32:43 +0100 Subject: [PATCH 26/26] Fix linting --- cuegui/cuegui/plugins/MonitorJobGraphPlugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py b/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py index 8b10e9b84..9d93e27f0 100644 --- a/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py +++ b/cuegui/cuegui/plugins/MonitorJobGraphPlugin.py @@ -61,12 +61,15 @@ def __init__(self, parent): cuegui.app().unmonitor.connect(self.__unmonitor) cuegui.app().facility_changed.connect(self.__setJob) + # pylint: disable=missing-function-docstring def dragEnterEvent(self, event): cuegui.Utils.dragEnterEvent(event) + # pylint: disable=missing-function-docstring def dragMoveEvent(self, event): cuegui.Utils.dragMoveEvent(event) + # pylint: disable=missing-function-docstring def dropEvent(self, event): for jobName in cuegui.Utils.dropEvent(event): self.__setJob(jobName)