view python/apps/diagrameditor.py @ 80:d1925eb3bbd5

Added hierarchy first things
author windel
date Wed, 14 Nov 2012 18:09:32 +0100
parents 44f075fe71eb
children 49161141d765
line wrap: on
line source

#!/usr/bin/python

from PyQt4.QtGui import *
from PyQt4.QtCore import *
import sys, json

"""
 Author: Windel Bouwman
 Year: 2012
 Description: This script implements a diagram editor.
 run with python 3.x as:
  $ python [thisfile.py]
"""

def buildPath(pts):
   path = QPainterPath(pts[0])
   for pt in pts[1:]: path.lineTo(pt)
   return path

def equalSpace(n, l, offset=15):
   if n == 1:
      return [l / 2]
   elif n > 1:
      return [offset + (l - offset*2)/(n - 1)*i for i in range(n)]
   return []

def uniqify(name, names):
   newname, i = name, 1
   while newname in names: newname, i = name + str(i), i + 1
   return newname

class Connection(QGraphicsPathItem):
   """ Implementation of a connection between blocks """
   def __init__(self, fromPort=None, toPort=None):
      super(Connection, self).__init__()
      self.pos2 = self.fromPort = self.toPort = None
      self.setFlags(self.ItemIsSelectable | self.ItemClipsToShape)
      pen = QPen(Qt.blue, 2, cap=Qt.RoundCap)
      self.setPen(pen)
      self.arrowhead = QGraphicsPathItem(self)
      self.arrowhead.setPath(buildPath([QPointF(0.0, 0.0), QPointF(-6.0, 10.0), QPointF(6.0, 10.0), QPointF(0.0, 0.0)]))
      self.arrowhead.setPen(pen)
      self.arrowhead.setBrush(QBrush(pen.color()))
      self.vias = []
      self.setFromPort(fromPort)
      self.setToPort(toPort)
   def getDict(self):
      d = {}
      d['fromBlock'] = self.fromPort.block.name
      d['fromPort'] = self.fromPort.name
      d['toBlock'] = self.toPort.block.name
      d['toPort'] = self.toPort.name
      return d
   def setDict(self, d):
      print(d)
   Dict = property(getDict, setDict)
   def myDelete(self):
      scene = self.scene()
      if scene:
         self.setFromPort(None)
         self.setToPort(None)
         scene.removeItem(self)
   def setFromPort(self, fromPort):
      if self.fromPort:
         self.fromPort.posCallbacks.remove(self.setBeginPos)
         self.fromPort.connection = None
      self.fromPort = fromPort
      if self.fromPort:
         self.fromPort.connection = self
         self.updateLineStukken()
         self.fromPort.posCallbacks.append(self.setBeginPos)
   def setToPort(self, toPort):
      if self.toPort:
         self.toPort.posCallbacks.remove(self.setEndPos)
         self.toPort.connection = None
      self.toPort = toPort
      if self.toPort:
         self.setEndPos(toPort.scenePos())
         self.toPort.connection = self
         self.toPort.posCallbacks.append(self.setEndPos)
   def getPos1(self):
      if self.fromPort:
         return self.fromPort.scenePos()
   def setBeginPos(self, pos1): self.updateLineStukken()
   def setEndPos(self, endpos):
      self.pos2 = endpos
      self.updateLineStukken()
   def itemChange(self, change, value):
      if change == self.ItemSelectedHasChanged:
         for via in self.vias:
            via.setVisible(value)
      return super(Connection, self).itemChange(change, value)
   def shape(self): return self.myshape
   def updateLineStukken(self):
      """
         This algorithm determines the optimal routing of all signals.
         TODO: implement nice automatic line router
      """
      pos1 = self.getPos1()
      if pos1 is None or self.pos2 is None:
         return
      pts = [pos1] + [v.pos() for v in self.vias] + [self.pos2]
      self.arrowhead.setPos(self.pos2)
      if pts[-1].x() < pts[-2].x():
         self.arrowhead.setRotation(-90)
      else:
         self.arrowhead.setRotation(90)
      self.setPath(buildPath(pts))
      """ Create a shape outline using the path stroker """
      s = super(Connection, self).shape()
      pps = QPainterPathStroker()
      pps.setWidth(10)
      self.myshape = pps.createStroke(s).simplified()

class ParameterDialog(QDialog):
   def __init__(self, block, parent = None):
      super(ParameterDialog, self).__init__(parent)
      self.block = block
      self.button = QPushButton('Ok', self)
      self.nameEdit = QLineEdit(self.block.name)
      self.codeEdit = QTextEdit(self)
      self.codeEdit.setPlainText(self.block.code)
      l = QFormLayout(self)
      l.addRow('Name:', self.nameEdit)
      l.addRow('Code:', self.codeEdit)
      l.addWidget(self.button)
      self.button.clicked.connect(self.OK)
   def OK(self):
      self.block.name = self.nameEdit.text()
      self.block.code = self.codeEdit.toPlainText()
      self.close()

class PortItem(QGraphicsPathItem):
   """ Represents a port to a subsystem """
   def __init__(self, name, block):
      super(PortItem, self).__init__(block)
      self.textItem = QGraphicsTextItem(self)
      self.connection = None
      self.block = block
      self.setCursor(QCursor(Qt.CrossCursor))
      self.setPen(QPen(Qt.blue, 2, cap=Qt.RoundCap))
      self.name = name
      self.posCallbacks = []
      self.setFlag(self.ItemSendsScenePositionChanges, True)
   def getName(self): return self.textItem.toPlainText()
   def setName(self, name):
      self.textItem.setPlainText(name)
      rect = self.textItem.boundingRect()
      lw, lh = rect.width(), rect.height()
      lx = 3 if type(self) is InputPort else -3 - lw
      self.textItem.setPos(lx, -lh / 2)
   name = property(getName, setName)
   def getDict(self): return {'name': self.name}
   Dict = property(getDict)
   def itemChange(self, change, value):
      if change == self.ItemScenePositionHasChanged:
         for cb in self.posCallbacks: cb(value)
         return value
      return super(PortItem, self).itemChange(change, value)

class OutputPort(PortItem):
   def __init__(self, name, block, d=10.0):
      super(OutputPort, self).__init__(name, block)
      self.setPath(buildPath([QPointF(0.0, -d), QPointF(d, 0), QPointF(0.0, d)]))
   def mousePressEvent(self, event):
      self.scene().startConnection(self)

class InputPort(PortItem):
   def __init__(self, name, block, d=10.0):
      super(InputPort, self).__init__(name, block)
      self.setPath(buildPath([QPointF(-d, -d), QPointF(0, 0), QPointF(-d, d)]))

class HandleItem(QGraphicsEllipseItem):
   """ A handle that can be moved by the mouse """
   def __init__(self, parent=None):
      dx = 13.0
      super(HandleItem, self).__init__(QRectF(-0.5*dx,-0.5*dx,dx,dx), parent)
      self.posChangeCallbacks = []
      self.setBrush(QBrush(Qt.white))
      self.setFlags(self.ItemSendsScenePositionChanges | self.ItemIsMovable)
      self.setVisible(False)
      self.setCursor(QCursor(Qt.SizeFDiagCursor))
   def mouseMoveEvent(self, event):
      """ Move function without moving the other selected elements """
      p = self.mapToParent(event.pos())
      self.setPos(p)
   def itemChange(self, change, value):
      if change == self.ItemPositionChange:
         for cb in self.posChangeCallbacks:
            res = cb(value)
            if res: value = res
         return value
      return super(HandleItem, self).itemChange(change, value)

class BlockItem(QGraphicsRectItem):
   """ Represents a block in the diagram """
   def __init__(self, name='Untitled', parent=None):
      super(BlockItem, self).__init__(parent)
      self.subModel = DiagramScene()
      self.subModel.containingBlock = self
      # Properties of the rectangle:
      self.setPen(QPen(Qt.blue, 2))
      self.setBrush(QBrush(Qt.lightGray))
      self.setFlags(self.ItemIsSelectable | self.ItemIsMovable | self.ItemSendsScenePositionChanges)
      self.setCursor(QCursor(Qt.PointingHandCursor))
      self.label = QGraphicsTextItem(name, self)
      self.name = name
      self.code = ''
      # Create corner for resize:
      self.sizer = HandleItem(self)
      self.sizer.posChangeCallbacks.append(self.changeSize) # Connect the callback
      button = QPushButton('+in')
      button.clicked.connect(self.newInputPort)
      self.buttonItemAddInput = QGraphicsProxyWidget(self)
      self.buttonItemAddInput.setWidget(button)
      self.buttonItemAddInput.setVisible(False)
      button = QPushButton('+out')
      button.clicked.connect(self.newOutputPort)
      self.buttonItemAddOutput = QGraphicsProxyWidget(self)
      self.buttonItemAddOutput.setWidget(button)
      self.buttonItemAddOutput.setVisible(False)
      # Inputs and outputs of the block:
      self.inputs = []
      self.outputs = []
      # Update size:
      self.sizer.setPos(QPointF(60, 40)) # This is a better resize function
   def editParameters(self):
      pd = ParameterDialog(self, self.window())
      pd.exec_()
   def mouseDoubleClickEvent(self, event):
      #self.editParameters()
      scene = self.scene()
      if scene:
         for view in scene.views():
            view.diagram = self.subModel
            view.zoomAll()
   def newInputPort(self):
      names = [i.name for i in self.inputs + self.outputs]
      self.addInput(InputPort(uniqify('in', names), self))
   def newOutputPort(self):
      names = [i.name for i in self.inputs + self.outputs]
      self.addOutput(OutputPort(uniqify('out', names), self))
   def setName(self, name): self.label.setPlainText(name)
   def getName(self): return self.label.toPlainText()
   name = property(getName, setName)
   def getDict(self):
      d = {'x': self.scenePos().x(), 'y': self.scenePos().y()}
      rect = self.rect()
      d.update({'width': rect.width(), 'height': rect.height()})
      d.update({'name': self.name, 'code': self.code})
      d['inputs'] = [inp.Dict for inp in self.inputs]
      d['outputs'] = [outp.Dict for outp in self.outputs]
      d['submodel'] = self.subModel.Dict
      return d
   def setDict(self, d):
      self.name = d['name']
      self.code = d['code']
      self.setPos(d['x'], d['y'])
      self.sizer.setPos(d['width'], d['height'])
      for inp in d['inputs']: self.addInput(InputPort(inp['name'], self))
      for outp in d['outputs']: self.addOutput(OutputPort(outp['name'], self))
      self.subModel.Dict = d['submodel']
   Dict = property(getDict, setDict)
   def addInput(self, i):
      self.inputs.append(i)
      self.updateSize()
   def addOutput(self, o):
      self.outputs.append(o)
      self.updateSize()
   def contextMenuEvent(self, event):
      menu = QMenu()
      pa = menu.addAction('Parameters')
      pa.triggered.connect(self.editParameters)
      menu.exec_(event.screenPos())
   def itemChange(self, change, value):
      if change == self.ItemSelectedHasChanged:
         for child in [self.sizer, self.buttonItemAddInput, self.buttonItemAddOutput]:
            child.setVisible(value)
      return super(BlockItem, self).itemChange(change, value)
   def myDelete(self):
         scene = self.scene()
         if scene:
            for p in self.inputs + self.outputs:
               if p.connection: p.connection.myDelete()
            scene.removeItem(self)
   def updateSize(self):
      rect = self.rect()
      h, w = rect.height(), rect.width()
      self.buttonItemAddInput.setPos(0, h + 4)
      self.buttonItemAddOutput.setPos(w+10, h+4)
      for inp, y in zip(self.inputs, equalSpace(len(self.inputs), h)):
         inp.setPos(0.0, y)
      for outp, y in zip(self.outputs, equalSpace(len(self.outputs), h)):
         outp.setPos(w, y)
   def changeSize(self, p):
      h = 20 if p.y() < 20 else p.y()
      w = 40 if p.x() < 40 else p.x()
      self.setRect(0.0, 0.0, w, h)
      rect = self.label.boundingRect()
      self.label.setPos((w - rect.width()) / 2, (h - rect.height()) / 2)
      self.updateSize()
      return QPointF(w, h)

class EditorGraphicsView(QGraphicsView):
   def __init__(self, parent=None):
      QGraphicsView.__init__(self, parent)
      self.setDragMode(QGraphicsView.RubberBandDrag)
      self.delShort = QShortcut(QKeySequence.Delete, self)
      self._model = None
      self.treeView = QTreeView()
   def setDiagram(self, d):
      self.setScene(d)
      self.delShort.activated.connect(d.deleteItems)
   def getModel(self): return self._model
   def setModel(self, m):
      self._model = m
      self.treeView.setModel(m)
      self.diagram = m.rootDiagram
   model = property(getModel, setModel)
   diagram = property(lambda s: s.scene(), setDiagram)
   def save(self):
      filename = 'diagram4.usd'
      with open(filename, 'w') as f:
         f.write(json.dumps(self.model.Dict))
   def load(self):
      filename = QFileDialog.getOpenFileName(self)
      self.model = loadModel(filename)
   def goUp(self):
      if hasattr(self.diagram, 'containingBlock'):
         self.diagram = self.diagram.containingBlock.scene()
         self.zoomAll()
   def zoomAll(self):
      """ zoom to fit all items """
      rect = self.diagram.itemsBoundingRect()
      self.fitInView(rect, Qt.KeepAspectRatio)
   def wheelEvent(self, event):
      pos = event.pos()
      posbefore = self.mapToScene(pos)
      degrees = event.delta() / 8.0
      sx = (100.0 + degrees) / 100.0
      self.scale(sx, sx)
      event.accept()
   def dragEnterEvent(self, event):
      if event.mimeData().hasFormat('component/name'): event.accept()
   def dragMoveEvent(self, event):
      if event.mimeData().hasFormat('component/name'): event.accept()
   def dropEvent(self, event):
      if event.mimeData().hasFormat('component/name'):
         name = bytes(event.mimeData().data('component/name')).decode()
         pos = self.mapToScene(event.pos())
         self.scene().addNewBlock(pos, name)
         # TODO: do this in a cleaner way:
         self.model.modelReset.emit()

class LibraryModel(QStandardItemModel):
   mimeTypes = lambda self: ['component/name']
   def mimeData(self, idxs):
      mimedata = QMimeData()
      for idx in idxs:
         if idx.isValid():
            txt = self.data(idx, Qt.DisplayRole)
            mimedata.setData('component/name', txt)
      return mimedata

class ModelHierarchyModel(QAbstractItemModel):
   def __init__(self):
      super(ModelHierarchyModel, self).__init__()
      self.rootDiagram = DiagramScene()
   def setDict(self, d):
      self.rootDiagram.Dict = d
      self.modelReset.emit()
   Dict = property(lambda s: s.rootDiagram.Dict, setDict)
   def index(self, row, column, parent=None):
      if parent.isValid():
         parent = parent.internalPointer().subModel
      else:
         parent = self.rootDiagram
      blocks = sorted(parent.blocks, key=lambda b:  b.name)
      block = blocks[row]
      # Store the index to retrieve it later in the parent function.
      # TODO: solve this in a better way.
      block.index = self.createIndex(row, column, block)
      return block.index
   def parent(self, index):
      if index.isValid():
         block = index.internalPointer()
         if block.scene() == self.rootDiagram:
            return QModelIndex()
         else:
            outerBlock = block.scene().containingBlock
            return outerBlock.index
      print('parent: No valid index')
   def data(self, index, role):
      if index.isValid() and role == Qt.DisplayRole:
         b = index.internalPointer()
         return b.name
   def headerData(self, section, orientation, role):
      if orientation == Qt.Horizontal and role == Qt.DisplayRole:
         return "Element"
   def rowCount(self, parent):
      if parent.column() > 0: return 0
      if parent.isValid():
         parent = parent.internalPointer().subModel
      else:
         parent = self.rootDiagram
      return len(parent.blocks)
   def columnCount(self, parent):
      return 1

class DiagramScene(QGraphicsScene):
   def __init__(self):
      super(DiagramScene, self).__init__()
      self.startedConnection = None
   blocks = property(lambda sel: [i for i in sel.items() if type(i) is BlockItem])
   connections = property(lambda sel: [i for i in sel.items() if type(i) is Connection])
   def setDict(self, d):
      for block in d['blocks']:
         b = BlockItem()
         self.addItem(b)
         b.Dict = block
      for con in d['connections']:
         fromPort = self.findPort(con['fromBlock'], con['fromPort'])
         toPort = self.findPort(con['toBlock'], con['toPort'])
         self.addItem(Connection(fromPort, toPort))
   def getDict(self):
      return {'blocks': [b.Dict for b in self.blocks], 'connections': [c.Dict for c in self.connections]}
   Dict = property(getDict, setDict)
   def findPort(self, blockname, portname):
      block = self.findBlock(blockname)
      if block:
         for port in block.inputs + block.outputs:
            if port.name == portname: return port
   def findBlock(self, blockname):
      for block in self.blocks:
         if block.name == blockname: return block
   def addNewBlock(self, pos, name):
      blocknames = [item.name for item in self.blocks]
      b1 = BlockItem(uniqify(name, blocknames))
      b1.setPos(pos)
      self.addItem(b1)
   def mouseMoveEvent(self, event):
      if self.startedConnection:
         pos = event.scenePos()
         self.startedConnection.setEndPos(pos)
      super(DiagramScene, self).mouseMoveEvent(event)
   def mouseReleaseEvent(self, event):
      if self.startedConnection:
         for item in self.items(event.scenePos()):
            if type(item) is InputPort and item.connection == None:
               self.startedConnection.setToPort(item)
               self.startedConnection = None
               return
         self.startedConnection.myDelete()
         self.startedConnection = None
      super(DiagramScene, self).mouseReleaseEvent(event)
   def startConnection(self, port):
      self.startedConnection = Connection(port, None)
      pos = port.scenePos()
      self.startedConnection.setEndPos(pos)
      self.addItem(self.startedConnection)
   def deleteItems(self):
      for item in list(self.selectedItems()): item.myDelete()

class LibraryWidget(QListView):
   def __init__(self):
      super(LibraryWidget, self).__init__(None)
      self.libraryModel = LibraryModel(self)
      self.libraryModel.setColumnCount(1)
      # Create an icon with an icon:
      pixmap = QPixmap(60, 60)
      pixmap.fill()
      painter = QPainter(pixmap)
      painter.fillRect(10, 10, 40, 40, Qt.blue)
      painter.setBrush(Qt.yellow)
      painter.drawEllipse(20, 20, 20, 20)
      painter.end()
      # Fill library:
      for name in ['Block', 'Uber unit', 'Device']:
         self.libraryModel.appendRow(QStandardItem(QIcon(pixmap), name))
      self.setModel(self.libraryModel)
      self.setViewMode(self.IconMode)
      self.setDragDropMode(self.DragOnly)

def loadModel(filename):
   m = ModelHierarchyModel()
   try:
      with open(filename, 'r') as f: data = f.read()
      m.Dict = json.loads(data)
   except FileNotFoundError: pass
   return m

class Main(QMainWindow):
   def __init__(self):
      super(Main, self).__init__(None)
      self.editor = EditorGraphicsView()
      self.setCentralWidget(self.editor)
      self.setWindowTitle("Diagram editor")
      toolbar = self.addToolBar('Tools')
      def act(name, shortcut, callback):
         a = QAction(name, self)
         a.setShortcuts(shortcut)
         a.triggered.connect(callback)
         toolbar.addAction(a)
      act('Save', QKeySequence.Save, self.editor.save)
      act('Load', QKeySequence.Open, self.editor.load)
      act('Full screen', QKeySequence("F11"), self.toggleFullScreen)
      act('Fit in view', QKeySequence("F8"), self.editor.zoomAll)
      act('Go up', QKeySequence(Qt.Key_Up), self.editor.goUp)
      def addDock(name, widget):
         dock = QDockWidget(name, self)
         dock.setWidget(widget)
         self.addDockWidget(Qt.LeftDockWidgetArea, dock)
      addDock('Library', LibraryWidget())
      addDock('Model tree', self.editor.treeView)
   def toggleFullScreen(self):
      self.setWindowState(self.windowState() ^ Qt.WindowFullScreen)
      self.editor.zoomAll()

if __name__ == '__main__':
   if sys.version_info.major != 3:
      print('Please use python 3.x')
      sys.exit(1)
   app = QApplication(sys.argv)
   main = Main()
   main.show()
   main.resize(700, 500)
   main.editor.model = loadModel('diagram4.usd')
   app.exec_()