diff --git a/NodeGraphQt/base/graph.py b/NodeGraphQt/base/graph.py index 699a944d..86ca4cc3 100644 --- a/NodeGraphQt/base/graph.py +++ b/NodeGraphQt/base/graph.py @@ -59,6 +59,7 @@ def __repr__(self): def _wire_signals(self): # 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) @@ -188,6 +189,26 @@ def _on_connection_changed(self, disconnected, connected): 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 = {'in': 'inputs', 'out': '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): """ diff --git a/NodeGraphQt/constants.py b/NodeGraphQt/constants.py index ce484935..8692d919 100644 --- a/NodeGraphQt/constants.py +++ b/NodeGraphQt/constants.py @@ -13,6 +13,7 @@ PIPE_DISABLED_COLOR = (190, 20, 20, 255) PIPE_ACTIVE_COLOR = (70, 255, 220, 255) PIPE_HIGHLIGHT_COLOR = (232, 184, 13, 255) +PIPE_SLICER_COLOR = (255, 50, 75) #: The draw the connection pipes as straight lines. PIPE_LAYOUT_STRAIGHT = 0 #: The draw the connection pipes as curved lines. diff --git a/NodeGraphQt/qgraphics/slicer.py b/NodeGraphQt/qgraphics/slicer.py new file mode 100644 index 00000000..6f99c3b6 --- /dev/null +++ b/NodeGraphQt/qgraphics/slicer.py @@ -0,0 +1,63 @@ +#!/usr/bin/python +from NodeGraphQt import QtCore, QtGui, QtWidgets +from NodeGraphQt.constants import Z_VAL_NODE_WIDGET, PIPE_SLICER_COLOR + + +class SlicerPipe(QtWidgets.QGraphicsPathItem): + """ + Base item used for drawing the pipe connection slicer. + """ + + def __init__(self): + super(SlicerPipe, 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(*PIPE_SLICER_COLOR) + p1 = self.path().pointAtPercent(0) + p2 = self.path().pointAtPercent(1) + size = 6.0 + offset = size / 2 + + painter.save() + painter.setRenderHint(painter.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(*PIPE_SLICER_COLOR) + text_color.setAlpha(80) + painter.setPen(QtGui.QPen(text_color, 1.5, QtCore.Qt.SolidLine)) + painter.drawText(text_pos, text) + + painter.setPen(QtGui.QPen(color, 1.5, QtCore.Qt.DashLine)) + painter.drawPath(self.path()) + + painter.setPen(QtGui.QPen(color, 1.5, QtCore.Qt.SolidLine)) + painter.setBrush(color) + + rect = QtCore.QRectF(p1.x() - offset, p1.y() - offset, size, size) + painter.drawEllipse(rect) + + rect = QtCore.QRectF(p2.x() - offset, p2.y() - offset, size, size) + painter.drawEllipse(rect) + painter.restore() + + def draw_path(self, p1, p2): + path = QtGui.QPainterPath() + path.moveTo(p1) + path.lineTo(p2) + self.setPath(path) diff --git a/NodeGraphQt/widgets/viewer.py b/NodeGraphQt/widgets/viewer.py index fd39ee00..805f90f6 100644 --- a/NodeGraphQt/widgets/viewer.py +++ b/NodeGraphQt/widgets/viewer.py @@ -12,6 +12,7 @@ from NodeGraphQt.qgraphics.node_backdrop import BackdropNodeItem from NodeGraphQt.qgraphics.pipe import Pipe from NodeGraphQt.qgraphics.port import PortItem +from NodeGraphQt.qgraphics.slicer import SlicerPipe from NodeGraphQt.widgets.scene import NodeScene from NodeGraphQt.widgets.stylesheet import STYLE_QMENU from NodeGraphQt.widgets.tab_search import TabSearchWidget @@ -30,6 +31,7 @@ class NodeViewer(QtWidgets.QGraphicsView): moved_nodes = QtCore.Signal(dict) search_triggered = QtCore.Signal(str, tuple) + connection_sliced = QtCore.Signal(list) connection_changed = QtCore.Signal(list, list) # pass through signals @@ -63,6 +65,10 @@ def __init__(self, parent=None): self._rubber_band = QtWidgets.QRubberBand( QtWidgets.QRubberBand.Rectangle, self ) + self._pipe_slicer = SlicerPipe() + self._pipe_slicer.setVisible(False) + self.scene().addItem(self._pipe_slicer) + self._undo_stack = QtWidgets.QUndoStack(self) self._context_menu = QtWidgets.QMenu('main', self) self._context_menu.setStyleSheet(STYLE_QMENU) @@ -126,6 +132,12 @@ def _on_search_submitted(self, node_type): pos = self.mapToScene(self._previous_pos) self.search_triggered.emit(node_type, (pos.x(), pos.y())) + def _on_pipes_sliced(self, path): + self.connection_sliced.emit([ + [i.input_port, i.output_port] + for i in self.scene().items(path) if isinstance(i, Pipe) + ]) + # --- reimplemented events --- def resizeEvent(self, event): @@ -138,6 +150,7 @@ def contextMenuEvent(self, event): def mousePressEvent(self, event): alt_modifier = event.modifiers() == QtCore.Qt.AltModifier shift_modifier = event.modifiers() == QtCore.Qt.ShiftModifier + if event.button() == QtCore.Qt.LeftButton: self.LMB_state = True elif event.button() == QtCore.Qt.RightButton: @@ -152,10 +165,19 @@ def mousePressEvent(self, event): if self._search_widget.isVisible(): self.tab_search_toggle() + # cursor pos. + map_pos = self.mapToScene(event.pos()) + + # pipe slicer enabled. + if event.modifiers() == (QtCore.Qt.AltModifier | QtCore.Qt.ShiftModifier): + self._pipe_slicer.draw_path(map_pos, map_pos) + self._pipe_slicer.setVisible(True) + return + if alt_modifier: return - items = self._items_near(self.mapToScene(event.pos()), None, 20, 20) + items = self._items_near(map_pos, None, 20, 20) nodes = [i for i in items if isinstance(i, AbstractNodeItem)] # toggle extend node selection. @@ -188,6 +210,13 @@ def mouseReleaseEvent(self, event): elif event.button() == QtCore.Qt.MiddleButton: self.MMB_state = False + # hide pipe slicer. + if self._pipe_slicer.isVisible(): + self._on_pipes_sliced(self._pipe_slicer.path()) + p = QtCore.QPointF(0.0, 0.0) + self._pipe_slicer.draw_path(p, p) + self._pipe_slicer.setVisible(False) + # hide selection marquee if self._rubber_band.isVisible(): rect = self._rubber_band.rect() @@ -211,6 +240,15 @@ def mouseReleaseEvent(self, event): def mouseMoveEvent(self, event): alt_modifier = event.modifiers() == QtCore.Qt.AltModifier shift_modifier = event.modifiers() == QtCore.Qt.ShiftModifier + if event.modifiers() == (QtCore.Qt.AltModifier | QtCore.Qt.ShiftModifier): + if self.LMB_state: + p1 = self._pipe_slicer.path().pointAtPercent(0) + p2 = self.mapToScene(self._previous_pos) + self._pipe_slicer.draw_path(p1, p2) + self._previous_pos = event.pos() + super(NodeViewer, self).mouseMoveEvent(event) + return + if self.MMB_state and alt_modifier: pos_x = (event.x() - self._previous_pos.x()) zoom = 0.1 if pos_x > 0 else -0.1 @@ -296,6 +334,10 @@ def sceneMousePressEvent(self, event): event (QtWidgets.QGraphicsScenePressEvent): The event handler from the QtWidgets.QGraphicsScene """ + # pipe slicer enabled. + if event.modifiers() == (QtCore.Qt.AltModifier | QtCore.Qt.ShiftModifier): + return + # viewer pan mode. if event.modifiers() == QtCore.Qt.AltModifier: return diff --git a/docs/_images/slicer.png b/docs/_images/slicer.png new file mode 100644 index 00000000..e587c74a Binary files /dev/null and b/docs/_images/slicer.png differ diff --git a/docs/_static/ngqt.css b/docs/_static/ngqt.css index 2d0a4130..ba554fe7 100644 --- a/docs/_static/ngqt.css +++ b/docs/_static/ngqt.css @@ -43,7 +43,8 @@ code span.pre { } /* tables */ -table.docutils td, table.docutils th { +table.docutils td, +table.docutils th { padding: 4px 8px; border-top: 0; border-left: 0; @@ -52,6 +53,10 @@ table.docutils td, table.docutils th { background: #24272b; } +table.align-center { + margin-left: unset; +} + /*-----------------------------------------*/ /* modules index */ diff --git a/docs/overview.rst b/docs/overview.rst index 11de6ab9..7f88d22c 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -17,6 +17,21 @@ Navigation | Pan | *Alt + LMB + Drag* or *MMB + Drag* | +---------------+----------------------------------------------+ +Port Connections +================ + +.. image:: _images/slicer.png + :width: 600px + +Connection pipes can be disconnected easily with the built in slice tool. + ++---------------------+----------------------------+ +| action | controls | ++=====================+============================+ +| Slice connections | *Alt + Shift + LMB + Drag* | ++---------------------+----------------------------+ + + Node Search =========== diff --git a/requirements.txt b/requirements.txt index 86e097c4..004812d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ PySide2>=5.12 Qt.py>=1.2.0.b2 -python>=3.6 \ No newline at end of file +python>=3.6 diff --git a/setup.py b/setup.py index 8f7411e9..a453a6a3 100644 --- a/setup.py +++ b/setup.py @@ -2,11 +2,14 @@ # -*- coding: utf-8 -*- import setuptools -from NodeGraphQt import __version__ as version +import NodeGraphQt with open('README.md', 'r') as fh: long_description = fh.read() +with open('requirements.txt') as f: + requirements = f.read().splitlines() + description = ( 'Node graph framework that can be re-implemented into applications that ' 'supports PySide & PySide2' @@ -19,7 +22,8 @@ setuptools.setup( name='NodeGraphQt', - version=version, + version=NodeGraphQt.__version__, + install_requires=requirements, author='Johnny Chan', author_email='johnny@chantasticvfx.com', description=description, @@ -28,7 +32,7 @@ url='https://github.com/jchanvfx/NodeGraphQt', packages=setuptools.find_packages(exclude=["example_nodes"]), classifiers=classifiers, - include_package_data=True, + include_package_data=True )