diff --git a/src/main/python/utils/Graphics.py b/src/main/python/utils/Graphics.py index 1e7e284..ca4c0ac 100644 --- a/src/main/python/utils/Graphics.py +++ b/src/main/python/utils/Graphics.py @@ -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 @@ -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): @@ -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() @@ -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.""" @@ -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): @@ -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): @@ -753,7 +777,6 @@ def mouseReleaseEvent(self, event): event.ignore() - def _set_all_sockets_visible(self, visible): if self.scene() is None: return @@ -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.""" @@ -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 diff --git a/tests/test_node_line_delete.py b/tests/test_node_line_delete.py new file mode 100644 index 0000000..23ec43f --- /dev/null +++ b/tests/test_node_line_delete.py @@ -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 \ No newline at end of file