#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), Laborejo2 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ import logging; logger = logging.getLogger(__name__); logger.info("import") from PyQt5 import QtCore, QtGui, QtSvg, QtWidgets from .constantsAndConfigs import constantsAndConfigs from template.qtgui.helper import stringToColor, removeInstancesFromScene, callContextMenu, stretchLine, stretchRect import engine.api as api class CCPath(QtWidgets.QGraphicsRectItem): """ A CCPath only exists when the backend track has a cc-part activated. Since each track has 128 potential CC paths we don't create and save a bunch of empty ones, neither in the backend nor here. Therefore: you cannot display a message "click here to create a CC track/block" here in the CC Path since the CCPath does not exist at this point in time. Thus this button is created in the track. One button for all CCs. There are two item types. Movable, or handle-points, and non-movable: interpolation-points. The backend only knows handle points and a function which interpolates until the next point. However, that results only in CC values 0-128 anyway. So we use these directly. We don't lie to the user. There is no smooth curve, not even a linear one, between CC values 65 and 66. It is just a step. So we show the steps.""" def __init__(self, parentGuiTrack, parentDataTrackId): super().__init__(0, 0, 0, 0) self.setAcceptHoverEvents(True) #self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable|QtWidgets.QGraphicsItem.ItemSendsGeometryChanges|QtWidgets.QGraphicsItem.ItemIsFocusable|QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) self.setFlags(QtWidgets.QGraphicsItem.ItemIsFocusable|QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) #self.setBrush(QtCore.Qt.red) #debug self.setPen(QtGui.QPen(QtCore.Qt.transparent)) self.parentDataTrackId = parentDataTrackId self.parentGuiTrack = parentGuiTrack self.userItems = [] self.interpolatedItems = [] self.other = [] self.blockListCache = [] #backend dictExport items. updated through self.updateGraphBlockTrack self.blockdictCache = {} #blockId:guiBlock updated through self.updateGraphBlockTrack self.transparentBlockHandles = [] #transparentBlockHandles in correct order. updated through self.updateGraphBlockTrack def itemChange(self, changeEnum, value): if changeEnum == QtWidgets.QGraphicsItem.ItemVisibleHasChanged: #12 if self.isVisible(): self.parentGuiTrack.universalCreateFirstCCBlock.hide() else: self.parentGuiTrack.universalCreateFirstCCBlock.show() return super().itemChange(changeEnum, value) def mousePressEvent(self, event): if event.button() == 1: #QtCore.Qt.LeftButton self.add(event.pos()) @property def items(self): return self.userItems + self.interpolatedItems + self.other #@property #def transparentBlockHandles(self): #return CCGraphTransparentBlock.instances This does not work because we need the blocks per track, not all of them in all tracks. def createGraphicItemsFromData(self, staticRepresentationList): """The tick position is, as always, a long tickvalue from the backend. The CC value, 0-127, has its default 0-line on the middle line as all items, since this is the track root. Since CC 64 is the middle value for CCs we shift all the points down by 64 so the middle staff line becomes CC 64. """ for item in self.items: self.scene().removeWhenIdle(item) self.other = [] self.userItems = [] self.interpolatedItems = [] for point in staticRepresentationList: if point["type"] == "interpolated": p = CCInterpolatedPoint() self.interpolatedItems.append(p) self.userItems[-1].interpolatedItemsRight.append(p) else: #user, lastInBlock or lastInTrack p = CCUserPoint(self, point) if self.userItems: p.interpolatedItemsLeft = self.userItems[-1].interpolatedItemsRight self.userItems.append(p) t = QtWidgets.QGraphicsSimpleTextItem(str(abs(point["value"]))) #t.setFont(constantsAndConfigs.theFont) t.setParentItem(self) t.setScale(0.75) self.other.append(t) t.setPos(point["position"] / constantsAndConfigs.ticksToPixelRatio, 29) p.setParentItem(self) p.setPos(point["position"] / constantsAndConfigs.ticksToPixelRatio , (point["value"]+64)*0.4375) #value goes from -0 to -127 and are compressed to fit between two lines in the staff. def updateGraphBlockTrack(self, staticRepresentationList): """Handles and visualizes block boundaries""" #{"type" : "GraphBlock", "id":id(block), "name":block.name, "duration":block.duration, "position":tickCounter} self.blockListCache = staticRepresentationList #sorted list self.blockdictCache = {} #reset for tbh in self.transparentBlockHandles: tbh.setParentItem(None) tbh.scene().removeWhenIdle(tbh) self.transparentBlockHandles = [] #reset #removeInstancesFromScene(CCGraphTransparentBlock) #this removes ALL blocks in all tracks from the scene. don't. for dictExportItem in staticRepresentationList: guiBlock = CCGraphTransparentBlock(parent = self, staticExportItem = dictExportItem, x = 0, y = -28, w = dictExportItem["duration"] / constantsAndConfigs.ticksToPixelRatio, h = 2*28) guiBlock.setParentItem(self) guiBlock.setPos(dictExportItem["position"] / constantsAndConfigs.ticksToPixelRatio,0) self.blockdictCache[dictExportItem["id"]] = guiBlock self.transparentBlockHandles.append(guiBlock) #Use the last item of the loop above to draw length of the boundary lines. maximumPixels = (dictExportItem["duration"] + dictExportItem["position"]) / constantsAndConfigs.ticksToPixelRatio upperLine = QtWidgets.QGraphicsLineItem(0,-28,maximumPixels,-28) #x1, y1, x2, y2 #upperLine.setPen(QtGui.QColor("red")) upperLine.setParentItem(self) self.other.append(upperLine) lowerLine = QtWidgets.QGraphicsLineItem(0,28,maximumPixels,28) #x1, y1, x2, y2 #lowerLine.setPen(QtGui.QColor("green")) lowerLine.setParentItem(self) self.other.append(lowerLine) upperLine.setOpacity(0.3) lowerLine.setOpacity(0.3) upperLine.setPos(0,0) lowerLine.setPos(0,0) #for self.stretchXCoordinates self.upperLine = upperLine self.lowerLine = lowerLine self.setRect(0,-28,maximumPixels,2*28) def stretchXCoordinates(self, factor): """Reposition the items on the X axis. Call goes through all parents/children, starting from ScoreView._stretchXCoordinates. Docstring there.""" stretchRect(self, factor) stretchLine(self.upperLine, factor) stretchLine(self.lowerLine, factor) for item in self.items: item.setX(item.pos().x() * factor) for transparentBlockHandle in self.transparentBlockHandles: transparentBlockHandle.setX(transparentBlockHandle.pos().x() * factor) transparentBlockHandle.stretchXCoordinates(factor) def blocKByPosition(self, tickPositionAbsoluteBackendValue): for block in self.blockListCache: start = block["position"] end = start + block["duration"] if start <= tickPositionAbsoluteBackendValue < end: return block["id"], block["position"] return None #After the last block. def blockAt(self, qScenePosition): """For compatibility with track.blockAt Can return a block or None for "after the last block" """ backendPosition = qScenePosition * constantsAndConfigs.ticksToPixelRatio result = self.blocKByPosition(backendPosition) if result: blockId, blockBackendPosition = result assert blockId in self.blockdictCache return self.blockdictCache[blockId] else: return None def getCCNumber(self): """The CC number is a dynamic value""" for ccNumber, ccGraphTrack in self.parentGuiTrack.ccPaths.items(): if ccGraphTrack is self: return ccNumber else: raise ValueError("This Block is not in the ccPath dict of the GuiTrack""") def getYAsBackendValue(self, y): newCCValue = int(y / 0.4375 - 64) #value goes from -0 to -127 and are compressed to fit between two lines in the staff. newCCValue = abs(newCCValue) assert 0 <= newCCValue < 128 return newCCValue def add(self, qPos): """Only activated through the hover area which gives are the most control over where clicks are allowed. Also there was a weird bug that the CCPath itself cannot detect mouse clicks in the right position""" y = qPos.y() if -28 < y < 28: sp = qPos.x() * constantsAndConfigs.ticksToPixelRatio blockId, blockStartOffset = self.blocKByPosition(sp) #get the block before rounding. Otherwise rounding might result in a position higher than the blocks duration if constantsAndConfigs.snapToGrid: sp = round(sp / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm positionInBlockBackendTicks = sp - blockStartOffset api.addGraphItem(blockId, positionInBlockBackendTicks, self.getYAsBackendValue(y)) class CCGraphTransparentBlock(QtWidgets.QGraphicsRectItem): instances = [] def __init__(self, parent, staticExportItem, x, y, w, h): self.__class__.instances.append(self) super().__init__(x, y, w, h) #self.setFlags(QtWidgets.QGraphicsItem.ItemDoesntPropagateOpacityToChildren|QtWidgets.QGraphicsItem.ItemIsMovable) #no mouseReleaseEvent without selection or movable. self.setFlags(QtWidgets.QGraphicsItem.ItemDoesntPropagateOpacityToChildren) #no mouseReleaseEvent without selection or movable. self.parentCCPath = parent self.parent = parent #for compatibility with block movement to the appending positon. self.parentGuiTrack = parent.parentGuiTrack #redundant, but specifically for block movement. see ScoreScene self.color = stringToColor(staticExportItem["name"]) self.setBrush(self.color) self.trans = QtGui.QColor("transparent") self.setPen(self.trans) self.setBrush(self.trans) self.setOpacity(0.9) #only visible during drag and move self.staticExportItem = staticExportItem self.blockEndMarker = CCGraphBlockEndMarker(self, staticExportItem) self.blockEndMarker.setParentItem(self) self.blockEndMarker.setPos(staticExportItem["duration"] / constantsAndConfigs.ticksToPixelRatio, 0) self.posBeforeMove = None self.cursorPosOnMoveStart = None def stretchXCoordinates(self, factor): """Reposition the items on the X axis. Call goes through all parents/children, starting from ScoreView._stretchXCoordinates. Docstring there.""" stretchRect(self, factor) self.blockEndMarker.setX(self.blockEndMarker.pos().x() * factor) def mousePressEventCustom(self, event): """Custom gets called by the scene mouse press event directly only when the right keys are hold down. e.g. shift + middle mouse button for moving this block""" self.posBeforeMove = self.pos() self.cursorPosOnMoveStart = QtGui.QCursor.pos() self.setBrush(self.color) super().mousePressEvent(event) def mouseMoveEventCustom(self, event): """Custom gets called by the scene mouse press event directly only when the right keys are hold down e.g. shift + middle mouse button for moving this block""" # All the positions below don't work. They work fine when dragging Tracks around but not this Item. I can't be bothered to figure out why. #scenePos() results ins an item position that is translated down and right. The higher the x/y value the more the offset #Instead we calculate our delta ourselves. #self.setPos(self.mapToItem(self, event.scenePos())) #self.setPos(self.mapFromScene(event.scenePos())) #posGlobal = QtGui.QCursor.pos() #posView = self.parentCCPath.parentScore.parentView.mapFromGlobal(posGlobal) #a widget #posScene = self.parentCCPath.parentScore.parentView.mapToScene(posView) #print (posGlobal, posView, posScene, event.scenePos()) if self.cursorPosOnMoveStart: self.setPos(event.scenePos()) """ #does not work with zooming if self.cursorPosOnMoveStart: delta = QtGui.QCursor.pos() - self.cursorPosOnMoveStart new = self.posBeforeMove + delta if new.x() < 0: #self.setPos(0, self.posBeforeMove.y()) self.setPos(0, new.y()) else: #self.setPos(new.x(), self.posBeforeMove.y()) #only move in one track self.setPos(new.x(), new.y()) #event.ignore() #this blocks the qt movable object since we already move the object on our own. """ super().mouseMoveEvent(event) def mouseReleaseEventCustom(self, event): """Custom gets called by the scene mouse press event directly only when the right keys are hold down""" self.setBrush(self.trans) self.setPos(self.posBeforeMove) #In case the block was moved to a position where no track is (below the tracks) we just reset the graphics. If the moving was correct then the new position will be set by redrawing the whole Conductor. self.posBeforeMove = None self.cursorPosOnMoveStart = None super().mouseReleaseEvent(event) def splitHere(self, event): posRelativeToBlockStart = event.scenePos().x() * constantsAndConfigs.ticksToPixelRatio - self.x() * constantsAndConfigs.ticksToPixelRatio if constantsAndConfigs.snapToGrid: posRelativeToBlockStart = round(posRelativeToBlockStart / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm if posRelativeToBlockStart > 0: api.splitCCBlock(self.staticExportItem["id"], int(posRelativeToBlockStart)) def contextMenuEvent(self, event): listOfLabelsAndFunctions = [ ("split here", lambda: self.splitHere(event)), ("duplicate", lambda: api.duplicateCCBlock(self.staticExportItem["id"])), ("create content link", lambda: api.duplicateContentLinkCCBlock(self.staticExportItem["id"])), ("unlink", lambda: api.unlinkCCBlock(self.staticExportItem["id"])), ("separator", None), ("join with next block", lambda: api.mergeWithNextGraphBlock(self.staticExportItem["id"])), ("delete block", lambda: api.deleteCCBlock(self.staticExportItem["id"])), ("separator", None), ("append block at the end", lambda ccNum = self.parentCCPath.getCCNumber(): api.appendGraphBlock(self.parentCCPath.parentDataTrackId, ccNum)), ] callContextMenu(listOfLabelsAndFunctions) class CCGraphBlockEndMarker(QtWidgets.QGraphicsLineItem): def __init__(self, parentTransparentBlock, staticExportItem): super(CCGraphBlockEndMarker, self).__init__(0,-28,0,28) #x, y, w, h self.parentTransparentBlock = parentTransparentBlock self.parentCCPath = self.parentTransparentBlock.parent self.staticExportItem = staticExportItem self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable|QtWidgets.QGraphicsItem.ItemSendsGeometryChanges|QtWidgets.QGraphicsItem.ItemIsFocusable) self.setAcceptHoverEvents(True) self.setCursor(QtCore.Qt.SizeHorCursor) self.lastPos = self.pos() #self.setBrush(QtGui.QColor("red")) pen = QtGui.QPen() # creates a default pen if not self.staticExportItem["exportsAllItems"]: pen.setStyle(QtCore.Qt.DashDotLine) pen.setWidth(2) self.setPen(pen) self.setZValue(10) self.inactivePen = pen self.inactivePen.setColor(QtGui.QColor("black")) self.activePen = QtGui.QPen(pen) self.activePen.setColor(QtGui.QColor("cyan")) def allItemsRightOfMe(self): for item in self.parentCCPath.items: if item.x() > self.x(): yield item def shape(self): """Qt Function Return a more accurate shape for this item so that mouse hovering is more accurate""" path = QtGui.QPainterPath() path.addRect(QtCore.QRectF(-3, -1/2 * constantsAndConfigs.trackHeight, 9, constantsAndConfigs.trackHeight)) #this is directly related to inits parameter x, y, w, h return path def mouseReleaseEvent(self, event): """After moving a point around send an update to the backend""" super(CCGraphBlockEndMarker, self).mouseReleaseEvent(event) if event.button() == 1: #QtCore.Qt.LeftButton x = event.scenePos().x() #x = self.x() x = x * constantsAndConfigs.ticksToPixelRatio endingRelativeToBlockStart = x - self.staticExportItem["position"] if constantsAndConfigs.snapToGrid: endingRelativeToBlockStart = round(endingRelativeToBlockStart / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm assert endingRelativeToBlockStart > 0 api.changeGraphBlockDuration(self.staticExportItem["id"], endingRelativeToBlockStart) def mousePressEvent(self, event): super(CCGraphBlockEndMarker, self).mousePressEvent(event) if event.button() == 1: #QtCore.Qt.LeftButton for i in self.allItemsRightOfMe(): i.hide() def mouseMoveEvent(self, event): """Only active when the item is also selected and left mouse button is down. Not any mouse event, no hover.""" super(CCGraphBlockEndMarker, self).mouseMoveEvent(event) self.setY(self.lastPos.y()) def hoverEnterEvent(self, event): #self.setZValue(20) #self.parentTransparentBlock.setZValue(19) self.setPen(self.activePen) self.parentTransparentBlock.setBrush(self.parentTransparentBlock.color) def hoverLeaveEvent(self, event): #self.setZValue(10) #self.parentTransparentBlock.setZValue(9) self.setPen(self.inactivePen) self.parentTransparentBlock.setBrush(self.parentTransparentBlock.trans) class CCInterpolatedPoint(QtWidgets.QGraphicsEllipseItem): """This is practically read-only""" def __init__(self): super(CCInterpolatedPoint, self).__init__(0, 0,1,1) #x,y,w,h self.setOpacity(0.6) self.setBrush(QtCore.Qt.black) #fill self.setEnabled(False) self.setZValue(1) class CCUserPoint(QtWidgets.QGraphicsEllipseItem): """the position is set by the parent""" def __init__(self, parentCCPath, staticExportItem): super(CCUserPoint, self).__init__(-3,-3,6,6) #x,y,w,h #color = QtGui.QColor() #color.setHsl(127-abs(value), 255, 127) #l(h, s, l) # 8bit values. Sets a HSL color value; h is the hue, s is the saturation, l is the lightness. l goes from black(0), over color(255/2) to white (255). #0 hue is green, 128 is red #self.setBrush(color) #fill self.setBrush(QtGui.QColor("cyan")) self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable|QtWidgets.QGraphicsItem.ItemSendsGeometryChanges|QtWidgets.QGraphicsItem.ItemIsFocusable) self.setAcceptHoverEvents(True) self.setCursor(QtCore.Qt.SizeAllCursor) self.parentCCPath = parentCCPath self.staticExportItem = staticExportItem self.interpolatedItemsRight = [] self.interpolatedItemsLeft = [] self.setZValue(9) def shape(self): """Return a more accurate shape for this item so that mouse hovering is more accurate""" path = QtGui.QPainterPath() path.addEllipse(QtCore.QRectF(-5, -5, 14, 14)) #this is directly related to inits parameter. -3, -3, 6, 6. return path def mousePressEvent(self, event): super().mousePressEvent(event) self.lastPos = self.pos() if event.button() == 1: #QtCore.Qt.LeftButton self.setCursor(QtCore.Qt.BlankCursor) for i in self.interpolatedItemsLeft + self.interpolatedItemsRight: i.hide() def mouseDoubleClickEvent(self, event): self.changeCCValue() def hoverEnterEvent(self, event): self.grabKeyboard() #self.setFocus() #do NOT set the focus. GrabKeyboard is enough. The focus will stay even after hoverLeaveEvent so that Delete will delete the last hovered item. not good! def hoverLeaveEvent(self, event): """reverse hoverEnterEvent""" self.ungrabKeyboard() def keyPressEvent(self, event): """Handle the delete item key. Needs grabKeyboard, but NOT setFocus""" key = event.key() if key == 16777223: self.delete() else: return super().keyPressEvent(event) def mouseReleaseEvent(self, event): """After moving a point around send an update to the backend""" super(CCUserPoint, self).mouseReleaseEvent(event) self.setCursor(QtCore.Qt.SizeAllCursor) if event.button() == 1: #QtCore.Qt.LeftButton api.changeGraphItem(self.staticExportItem["id"], self.getXDifferenceAsBackendValue(), self.getYAsBackendValue()) #send update to the backend, don't wait for callback. def mouseMoveEvent(self, event): """Only active when the item is also selected and left mouse button is down. Not any mouse event, no hover.""" modifiers = QtWidgets.QApplication.keyboardModifiers() super(CCUserPoint, self).mouseMoveEvent(event) if modifiers == QtCore.Qt.ShiftModifier: self.setY(self.lastPos.y()) elif modifiers == QtCore.Qt.AltModifier: self.setX(self.lastPos.x()) absoluteXPosition = self.x() * constantsAndConfigs.ticksToPixelRatio withinBlock = self.staticExportItem["minPossibleAbsolutePosition"] <= absoluteXPosition <= self.staticExportItem["maxPossibleAbsolutePosition"] if not withinBlock: if absoluteXPosition < self.staticExportItem["minPossibleAbsolutePosition"]: self.setX(self.staticExportItem["minPossibleAbsolutePosition"] / constantsAndConfigs.ticksToPixelRatio) else: self.setX(self.staticExportItem["maxPossibleAbsolutePosition"] / constantsAndConfigs.ticksToPixelRatio) #Make mouse cursor movement not weird. a = self.scene().parentView.mapFromScene(self.pos()) b = self.scene().parentView.viewport().mapToGlobal(a) QtGui.QCursor.setPos(b.x(), QtGui.QCursor.pos().y()) withinYRange = -28 <= self.y() <= 28 if not withinYRange: if self.y() < -28: self.setY(-27.9) else: self.setY(28) #Make mouse cursor movement not weird. a = self.scene().parentView.mapFromScene(self.pos()) b = self.scene().parentView.viewport().mapToGlobal(a) QtGui.QCursor.setPos(QtGui.QCursor.pos().x(), b.y()) def delete(self): """Instruct the backend to delete this item. Will trigger a callback to redraw the graphTrack""" api.removeGraphItem(self.staticExportItem["id"]) def contextMenuEvent(self, event): menu = QtWidgets.QMenu() listOfLabelsAndFunctions = [ ("interpolation Type", self.changeInterpolationType), ("CC Value", self.changeCCValue), ("delete", self.delete), #deletes the last hover item which is of course the current one. ] for text, function in listOfLabelsAndFunctions: if text == "separator": self.addSeparator() else: a = QtWidgets.QAction(text, menu) menu.addAction(a) a.triggered.connect(function) pos = QtGui.QCursor.pos() pos.setY(pos.y() + 5) self.ungrabMouse() #if we don't ungrab and the user clicks the context menu "away" by clicking in an empty area this will still get registered as mouseclick belonging to the current item and change the value to some insane amount, breaking the point. menu.exec_(pos) def changeInterpolationType(self): graphTypeString = QtWidgets.QInputDialog.getItem(self.scene().parentView, "Interpolation Type", "choose Interpolation Type", api.getListOfGraphInterpolationTypesAsStrings(), 0, False) if graphTypeString[1]: #[1] bool if canceled api.changeGraphItemInterpolation(self.staticExportItem["id"], graphTypeString[0]) def changeCCValue(self): ccValue = QtWidgets.QInputDialog.getInt(self.scene().parentView, "CC Value", "specify midi Continuous Control value", value=64, minValue=0, maxValue=127) if ccValue[1]: #[1] bool if canceled api.changeGraphItem(self.staticExportItem["id"], self.getXDifferenceAsBackendValue(), ccValue[0]) #send update to the backend, don't wait for callback. def getYAsBackendValue(self): newCCValue = int(self.y() / 0.4375 - 64) #value goes from -0 to -127 and are compressed to fit between two lines in the staff. newCCValue = abs(newCCValue) assert 0 <= newCCValue < 128 return newCCValue def getXDifferenceAsBackendValue(self): """The problem is that we need the position relativ to its parent block, which we don't use in that way in the GUI. So we calculate the difference to the old position""" oldPositionAbsolute = int(self.staticExportItem["position"]) newPositionAbsolute = self.pos().x() * constantsAndConfigs.ticksToPixelRatio if constantsAndConfigs.snapToGrid: newPositionAbsolute = round(newPositionAbsolute / constantsAndConfigs.gridRhythm) * constantsAndConfigs.gridRhythm diff = int(-1 * (oldPositionAbsolute - newPositionAbsolute)) return diff