Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 114 additions & 17 deletions src/main/python/utils/Graphics.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ def load_canvas_from_snapshot(self, snapshot, container):
class NodeLine(QtWidgets.QGraphicsPathItem):
HOP_RADIUS = 6 # size of the bend drawn where two lines cross

def __init__(self, pointA, pointB , socket):
def __init__(self, pointA, pointB, socket):
super(NodeLine, self).__init__()
self._route = []
self._previous_scene = None
Expand All @@ -529,13 +529,19 @@ def __init__(self, pointA, pointB , socket):
self._target = None
self._dragging_free_endpoint = False
self._free_endpoint_radius = 12
self._hovered = False
self.setZValue(-1)
self.setAcceptedMouseButtons(QtCore.Qt.LeftButton)
self.setBrush(QtGui.QColor(0,70,70,120))
self.setFlags(
QtWidgets.QGraphicsItem.ItemIsSelectable
| QtWidgets.QGraphicsItem.ItemIsFocusable
)
self.setAcceptHoverEvents(True)
self.setAcceptedMouseButtons(QtCore.Qt.LeftButton | QtCore.Qt.RightButton)
self.setBrush(QtGui.QColor(0, 70, 70, 120))
self.pen = QtGui.QPen()
self.pen.setStyle(QtCore.Qt.SolidLine)
self.pen.setWidth(2)
self.pen.setColor(QtGui.QColor(0,70,70,220))
self.pen.setColor(QtGui.QColor(0, 70, 70, 220))
self.setPen(self.pen)

def _compute_route(self):
Expand Down Expand Up @@ -606,7 +612,6 @@ def _find_hops(self):
hops.setdefault(i, []).append(vx)
return hops


def _rebuild_path(self):
"""Draw the route, adding a semicircular hop at every crossing."""
path = QtGui.QPainterPath()
Expand Down Expand Up @@ -675,8 +680,9 @@ def shape(self):
))
return path

return super(NodeLine, self).shape()

stroker = QtGui.QPainterPathStroker()
stroker.setWidth(10.0)
return stroker.createStroke(self.path())

def _refresh_crossing_lines(self, rect, scene=None):
"""Redraw other lines in `rect`, since their hops over this line may have changed."""
Expand Down Expand Up @@ -704,7 +710,14 @@ def itemChange(self, change, value):
return super(NodeLine, self).itemChange(change, value)

def paint(self, painter, option, widget):
painter.setPen(self.pen)
pen = QtGui.QPen(self.pen)
if self.isSelected():
pen.setColor(QtGui.QColor(220, 53, 69))
pen.setWidth(3)
elif getattr(self, "_hovered", False):
pen.setColor(QtGui.QColor(0, 123, 255))
pen.setWidth(3)
painter.setPen(pen)
painter.drawPath(self.path())

def mousePressEvent(self, event):
Expand All @@ -723,6 +736,17 @@ def mousePressEvent(self, event):
event.accept()
return

event.ignore()
return

if event.button() in (QtCore.Qt.LeftButton, QtCore.Qt.RightButton):
if self.scene() and not (event.modifiers() & QtCore.Qt.ControlModifier):
self.scene().clearSelection()
self.setSelected(True)
self.setFocus()
event.accept()
return

event.ignore()

def mouseMoveEvent(self, event):
Expand Down Expand Up @@ -753,7 +777,6 @@ def mouseReleaseEvent(self, event):

event.ignore()


def _set_all_sockets_visible(self, visible):
if self.scene() is None:
return
Expand All @@ -772,7 +795,6 @@ def _set_all_sockets_visible(self, visible):
else:
socket.hide()


def _try_reconnect(self, scene_pos):
"""Reconnect a partial line to a compatible free socket."""

Expand Down Expand Up @@ -847,40 +869,115 @@ def _try_reconnect(self, scene_pos):
print("[DEBUG] Partial line successfully reconnected.")
return True

def hoverEnterEvent(self, event):
self._hovered = True
while QtWidgets.QApplication.overrideCursor() is not None:
QtWidgets.QApplication.restoreOverrideCursor()
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.PointingHandCursor)
if self.scene() and self.scene().views():
self.scene().views()[0].viewport().setCursor(QtCore.Qt.PointingHandCursor)
self.update()
super(NodeLine, self).hoverEnterEvent(event)

def hoverLeaveEvent(self, event):
self._hovered = False
while QtWidgets.QApplication.overrideCursor() is not None:
QtWidgets.QApplication.restoreOverrideCursor()
if self.scene() and self.scene().views():
self.scene().views()[0].viewport().unsetCursor()
self.update()
super(NodeLine, self).hoverLeaveEvent(event)

def delete_connection(self):
"""Delete this connection line, clean sockets/model, and refresh editability."""
if getattr(self, "_hovered", False):
self._hovered = False
while QtWidgets.QApplication.overrideCursor() is not None:
QtWidgets.QApplication.restoreOverrideCursor()
if self.scene() and self.scene().views():
self.scene().views()[0].viewport().unsetCursor()

container = None
if self.source and getattr(self.source, "parent", None):
container = getattr(self.source.parent, "container", None)
if container is None and self.target and getattr(self.target, "parent", None):
container = getattr(self.target.parent, "container", None)

if container and hasattr(container, "delete"):
container.delete([self])
return

if self.source and getattr(self.source, "parent", None) and hasattr(self.source.parent.obj, "remove_connection"):
self.source.parent.obj.remove_connection(0, self.source.id)
if self.target and getattr(self.target, "parent", None) and hasattr(self.target.parent.obj, "remove_connection"):
self.target.parent.obj.remove_connection(1, self.target.id)

if self.source and self in getattr(self.source, "out_lines", []):
self.source.out_lines.remove(self)
if self.target and self in getattr(self.target, "in_lines", []):
self.target.in_lines.remove(self)

if self.scene():
self.scene().removeItem(self)

def contextMenuEvent(self, event):
if self.scene():
self.scene().clearSelection()
self.setSelected(True)

menu = QtWidgets.QMenu()
delete_action = menu.addAction("Delete Connection")
action = menu.exec_(event.screenPos())
if action == delete_action:
self.delete_connection()

def keyPressEvent(self, event):
if event.key() in (QtCore.Qt.Key_Delete, QtCore.Qt.Key_Backspace):
self.delete_connection()
event.accept()
return
super(NodeLine, self).keyPressEvent(event)

@property
def pointA(self):
return self._pointA

@pointA.setter
def pointA(self, point):
self._pointA = point
self.updatePath()

@property
def pointB(self):
return self._pointB

@pointB.setter
def pointB(self, point):
self._pointB = point
self.updatePath()

@property
def source(self):
return self._source

@source.setter
def source(self, widget):
self._source = widget

@property
def target(self):
return self._target

@target.setter
def target(self, widget):
self._target = widget

def __delete__(self, instance):
del self._source
del self._target
del self._pointA
del self._pointB

def __delete__(self,instance):
del self._source
del self._target
Expand Down
133 changes: 133 additions & 0 deletions tests/test_node_line_delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import os
import sys
from unittest.mock import MagicMock
import pytest
from PyQt5 import QtCore, QtGui, QtWidgets

PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
for p in [os.path.join(PROJECT_ROOT, 'src', 'main'), os.path.join(PROJECT_ROOT, 'src', 'main', 'python')]:
if p not in sys.path:
sys.path.insert(0, p)

app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication(sys.argv)

from python.utils.Graphics import NodeLine, NodeSocket
from python.utils.Container import Container


class DummyNodeItem(QtWidgets.QGraphicsRectItem):
def __init__(self, container=None):
super().__init__(0, 0, 40, 40)
self.obj = MagicMock()
self.container = container


def _make_connected_line(scene, container=None):
src_node = DummyNodeItem(container=container)
tgt_node = DummyNodeItem(container=container)

src_socket = NodeSocket(QtCore.QRectF(0, 0, 10, 10), src_node, 'op', 0)
tgt_socket = NodeSocket(QtCore.QRectF(100, 0, 10, 10), tgt_node, 'in', 0)

line = NodeLine(QtCore.QPointF(5, 5), QtCore.QPointF(105, 5), src_socket)
line.source = src_socket
line.target = tgt_socket
src_socket.out_lines.append(line)
tgt_socket.in_lines.append(line)
line.updatePath()
scene.addItem(line)
return line, src_socket, tgt_socket, src_node, tgt_node


def test_nodeline_is_selectable_and_has_wide_hitbox():
scene = QtWidgets.QGraphicsScene()
line, *_ = _make_connected_line(scene)

assert line.flags() & QtWidgets.QGraphicsItem.ItemIsSelectable
assert line.flags() & QtWidgets.QGraphicsItem.ItemIsFocusable
assert line.shape().contains(QtCore.QPointF(50, 7))


def test_nodeline_hover_and_selection_paint():
scene = QtWidgets.QGraphicsScene()
line, *_ = _make_connected_line(scene)

line.hoverEnterEvent(None)
assert line._hovered is True
line.hoverLeaveEvent(None)
assert line._hovered is False

line.setSelected(True)
img = QtGui.QImage(150, 50, QtGui.QImage.Format_ARGB32)
painter = QtGui.QPainter(img)
line.paint(painter, QtWidgets.QStyleOptionGraphicsItem(), None)
painter.end()

def test_nodeline_delete_via_container_updates_editability():
scene = QtWidgets.QGraphicsScene()
view = MagicMock()
view.scene.return_value = scene
container = Container(MagicMock(), view)
container.update_components_editability = MagicMock()
container.graphics.push_snapshot = MagicMock()

line, src_socket, tgt_socket, src_node, tgt_node = _make_connected_line(scene, container)
assert line in scene.items()

line.delete_connection()

assert line not in scene.items()
assert line not in src_socket.out_lines
assert line not in tgt_socket.in_lines
src_node.obj.remove_connection.assert_called_once_with(0, src_socket.id)
tgt_node.obj.remove_connection.assert_called_once_with(1, tgt_socket.id)
container.update_components_editability.assert_called_once()
container.graphics.push_snapshot.assert_called_once()


def test_nodeline_keyboard_delete_key():
scene = QtWidgets.QGraphicsScene()
line, src_socket, tgt_socket, *_ = _make_connected_line(scene)
line.setSelected(True)

key_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, QtCore.Qt.Key_Delete, QtCore.Qt.NoModifier)
line.keyPressEvent(key_event)

assert key_event.isAccepted()
assert line not in scene.items()
assert src_socket.out_lines == []
assert tgt_socket.in_lines == []


def test_nodeline_context_menu_delete(monkeypatch):
scene = QtWidgets.QGraphicsScene()
line, src_socket, tgt_socket, *_ = _make_connected_line(scene)

fake_action = MagicMock()
fake_menu = MagicMock()
fake_menu.addAction.return_value = fake_action
fake_menu.exec_.return_value = fake_action
monkeypatch.setattr(QtWidgets, "QMenu", lambda: fake_menu)

event = MagicMock()
event.screenPos.return_value = QtCore.QPoint(10, 10)
line.contextMenuEvent(event)

assert line not in scene.items()
assert src_socket.out_lines == []
assert tgt_socket.in_lines == []


def test_deleting_one_line_preserves_other_connections():
scene = QtWidgets.QGraphicsScene()
line1, src1, tgt1, *_ = _make_connected_line(scene)
line2, src2, tgt2, *_ = _make_connected_line(scene)

line1.delete_connection()

assert line1 not in scene.items()
assert line2 in scene.items()
assert line2 in src2.out_lines
assert line2 in tgt2.in_lines
Loading