You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

578 lines
27 KiB

4 years ago
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
Copyright 2022, Nils Hilbricht, Germany ( )
4 years ago
4 years ago
This file is part of the Laborejo Software Suite ( ),
4 years ago
4 years ago
Laborejo2 is free software: you can redistribute it and/or modify
4 years ago
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
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__);"import")
4 years ago
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.setBrush( #debug
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():
return super().itemChange(changeEnum, value)
def mousePressEventToAdd(self, event):
if event.button() == 1: #QtCore.Qt.LeftButton
4 years ago
def items(self):
return self.userItems + self.interpolatedItems + self.other
#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
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.other = []
self.userItems = []
self.interpolatedItems = []
for point in staticRepresentationList:
if point["type"] == "interpolated":
p = CCInterpolatedPoint()
else: #user, lastInBlock or lastInTrack
p = CCUserPoint(self, point)
if self.userItems:
p.interpolatedItemsLeft = self.userItems[-1].interpolatedItemsRight
t = QtWidgets.QGraphicsSimpleTextItem(str(abs(point["value"])))
4 years ago
t.setPos(point["position"] / constantsAndConfigs.ticksToPixelRatio, 29)
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", "duration":block.duration, "position":tickCounter}
self.blockListCache = staticRepresentationList #sorted list
self.blockdictCache = {} #reset
for tbh in self.transparentBlockHandles:
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.setPos(dictExportItem["position"] / constantsAndConfigs.ticksToPixelRatio,0)
self.blockdictCache[dictExportItem["id"]] = 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
lowerLine = QtWidgets.QGraphicsLineItem(0,28,maximumPixels,28) #x1, y1, x2, y2
#for self.stretchXCoordinates
self.upperLine = upperLine
self.lowerLine = lowerLine
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)
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]
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
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, event):
4 years ago
"""Only activated through the hover area which gives are the
most control over where clicks are allowed.
pos = self.mapFromScene(event.scenePos())
x = pos.x()
y = pos.y()
4 years ago
if -28 < y < 28:
sp = x * constantsAndConfigs.ticksToPixelRatio
4 years ago
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):
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.
2 years ago
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
4 years ago
self.color = stringToColor(staticExportItem["name"])
self.trans = QtGui.QColor("transparent")
2 years ago
self.setOpacity(0.9) #only visible during drag and move
4 years ago
self.staticExportItem = staticExportItem
self.blockEndMarker = CCGraphBlockEndMarker(self, staticExportItem)
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()
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()))
#posGlobal = QtGui.QCursor.pos()
2 years ago
#posView = self.parentCCPath.parentScore.parentView.mapFromGlobal(posGlobal) #a widget
#posScene = self.parentCCPath.parentScore.parentView.mapToScene(posView)
4 years ago
#print (posGlobal, posView, posScene, event.scenePos())
if self.cursorPosOnMoveStart:
#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())
#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.
def mouseReleaseEventCustom(self, event):
"""Custom gets called by the scene mouse press event directly only when the right keys
are hold down"""
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
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),
2 years ago
("append block at the end", lambda ccNum = self.parentCCPath.getCCNumber(): api.appendGraphBlock(self.parentCCPath.parentDataTrackId, ccNum)),
4 years ago
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.lastPos = self.pos()
pen = QtGui.QPen() # creates a default pen
if not self.staticExportItem["exportsAllItems"]:
self.inactivePen = pen
self.activePen = QtGui.QPen(pen)
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
4 years ago
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
4 years ago
for i in self.allItemsRightOfMe():
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)
def hoverEnterEvent(self, event):
def hoverLeaveEvent(self, event):
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.setBrush( #fill
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.parentCCPath = parentCCPath
self.staticExportItem = staticExportItem
self.interpolatedItemsRight = []
self.interpolatedItemsLeft = []
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):
self.lastPos = self.pos()
if event.button() == 1: #QtCore.Qt.LeftButton
4 years ago
for i in self.interpolatedItemsLeft + self.interpolatedItemsRight:
def mouseDoubleClickEvent(self, event):
def hoverEnterEvent(self, event):
#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"""
def keyPressEvent(self, event):
"""Handle the delete item key.
Needs grabKeyboard, but NOT setFocus"""
key = event.key()
if key == 16777223:
return super().keyPressEvent(event)
def mouseReleaseEvent(self, event):
"""After moving a point around
send an update to the backend"""
super(CCUserPoint, self).mouseReleaseEvent(event)
if event.button() == 1: #QtCore.Qt.LeftButton
4 years ago
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:
elif modifiers == QtCore.Qt.AltModifier:
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)
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:
#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"""
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":
a = QtWidgets.QAction(text, menu)
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.
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 control change value", 64, 0, 127) #value, min, max.
4 years ago
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