Ilya ae3f752b6f feat(ui): panels, canvas view, animation, and main window
Add left node-library dock (grouped, searchable, drag-and-drop vector
symbols), right property dock with type-aware grouped editors (incl.
geometry, catalogue single/multi, color, value list), zoom/pan/fit canvas
view accepting node drops, flow animator, configuration dialog, and the
MainWindow wiring menus/toolbar, undo/redo, delete/duplicate, calculate
flow, save/open (JSON), and PNG/SVG export. Includes a demo sample network,
entry points, and panel + app integration tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:25:14 +02:00

100 lines
3.7 KiB
Python

"""CanvasView: zoomable/pannable QGraphicsView that accepts node drops."""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPainter
from PyQt6.QtWidgets import QGraphicsView
from ..panels.node_panel import NODE_MIME
MIN_SCALE = 0.15
MAX_SCALE = 6.0
class CanvasView(QGraphicsView):
def __init__(self, scene, parent=None):
super().__init__(scene, parent)
self.setRenderHints(
QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
self.setAcceptDrops(True)
self._scale = 1.0
self._panning = False
# -- zoom -------------------------------------------------------------
def wheelEvent(self, event):
factor = 1.2 if event.angleDelta().y() > 0 else 1 / 1.2
new_scale = self._scale * factor
if new_scale < MIN_SCALE or new_scale > MAX_SCALE:
return
self._scale = new_scale
self.scale(factor, factor)
def reset_zoom(self):
self.resetTransform()
self._scale = 1.0
def fit_to_contents(self):
rect = self.scene().itemsBoundingRect()
if rect.isNull():
return
self.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.AspectRatioMode.KeepAspectRatio)
self._scale = self.transform().m11()
# -- panning (space or middle mouse) ----------------------------------
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Space and not self._panning:
self._panning = True
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
super().keyPressEvent(event)
def keyReleaseEvent(self, event):
if event.key() == Qt.Key.Key_Space:
self._panning = False
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
super().keyReleaseEvent(event)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.MiddleButton:
self._panning = True
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
# forward as a left press so ScrollHandDrag engages
from PyQt6.QtGui import QMouseEvent
fake = QMouseEvent(event.type(), event.position(), event.globalPosition(),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
event.modifiers())
super().mousePressEvent(fake)
return
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.MiddleButton and self._panning:
self._panning = False
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
return
super().mouseReleaseEvent(event)
# -- drops from the node panel ---------------------------------------
def dragEnterEvent(self, event):
if event.mimeData().hasFormat(NODE_MIME):
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
def dragMoveEvent(self, event):
if event.mimeData().hasFormat(NODE_MIME):
event.acceptProposedAction()
else:
super().dragMoveEvent(event)
def dropEvent(self, event):
if event.mimeData().hasFormat(NODE_MIME):
key = bytes(event.mimeData().data(NODE_MIME)).decode("utf-8")
scene_pos = self.mapToScene(event.position().toPoint())
self.scene().create_node(key, scene_pos.x(), scene_pos.y())
event.acceptProposedAction()
else:
super().dropEvent(event)