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.
1175 lines
58 KiB
1175 lines
58 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2017, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of Laborejo ( https://www.laborejo.org )
|
|
|
|
Laborejo 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 <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
from math import log
|
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
|
|
|
from .items import staticItem2Item, GuiTieCurveGraphicsItem
|
|
from .constantsAndConfigs import constantsAndConfigs
|
|
from .cursor import Cursor, Playhead, Selection
|
|
from .conductor import Conductor, ConductorTransparentBlock
|
|
from .graphs import CCGraphTransparentBlock
|
|
from template.qtgui.helper import stretchLine, stretchRect, callContextMenu, removeInstancesFromScene
|
|
from .submenus import BlockPropertiesEdit
|
|
from . import graphs
|
|
from hashlib import md5 #string to color
|
|
import engine.api as api
|
|
|
|
oneRectToReturnThemAll = QtCore.QRectF(0,0,0,0) #prevent the annoying "NotImplementError" from Qt for boundingRect. #TODO: implement in each class for realsies
|
|
cosmeticPen = QtGui.QPen()
|
|
cosmeticPen.setCosmetic(True)
|
|
|
|
|
|
class GuiBlockHandle(QtWidgets.QGraphicsRectItem):
|
|
"""A simplified version of a Block. Since we don't use blocks in the GUI, only in the backend
|
|
we still need them sometimes as macro strutures, where we don't care about the content.
|
|
This is the transparent Block handle that appears when the user uses the mouse to drag and drop
|
|
a block with shift + middle mouse button.
|
|
|
|
It is visible all the time though and can be clicked on. In opposite to the background color,
|
|
which is just a color and stays in place.
|
|
"""
|
|
def __init__(self, parent, staticExportItem, x, y, w, h):
|
|
super().__init__(x, y, w, h)
|
|
self.parent = parent #GuiTrack instance
|
|
self.color = None #inserted by the creating function in GuiTrack
|
|
self.trans = QtGui.QColor("transparent")
|
|
self.setPen(self.trans)
|
|
self.setBrush(self.trans)
|
|
self.setOpacity(0.4) #slightly fuller than background
|
|
self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity)
|
|
self.setParentItem(parent)
|
|
self.setZValue(10) #This is the z value within GuiTrack
|
|
self.staticExportItem = staticExportItem
|
|
|
|
self.posBeforeMove = None
|
|
self.cursorPosOnMoveStart = None
|
|
|
|
#Display Block ID
|
|
"""
|
|
self.idText = QtWidgets.QGraphicsSimpleTextItem(str(self.staticExportItem["id"]))
|
|
self.idText.setParentItem(self)
|
|
self.idText.setPos(0, constantsAndConfigs.stafflineGap)
|
|
#self.idText.setScale(4)
|
|
self.idText.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity)
|
|
"""
|
|
|
|
if self.staticExportItem["completeDuration"] >= api.D1: #cosmetics
|
|
self.startLabel = QtWidgets.QGraphicsSimpleTextItem(self.staticExportItem["name"] + " start")
|
|
self.startLabel.setParentItem(self)
|
|
self.startLabel.setPos(0, constantsAndConfigs.stafflineGap)
|
|
self.startLabel.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity)
|
|
|
|
self.endLabel = QtWidgets.QGraphicsSimpleTextItem(self.staticExportItem["name"] + " end ")
|
|
self.endLabel.setParentItem(self)
|
|
self.endLabel.setPos(self.rect().width() - self.endLabel.boundingRect().width(), constantsAndConfigs.stafflineGap)
|
|
self.endLabel.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity)
|
|
else:
|
|
self.startLabel = QtWidgets.QGraphicsSimpleTextItem("")
|
|
self.endLabel = QtWidgets.QGraphicsSimpleTextItem("")
|
|
|
|
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.endLabel.setPos(self.rect().width() - self.endLabel.boundingRect().width(), constantsAndConfigs.stafflineGap)
|
|
|
|
def blockMode(self):
|
|
self.startLabel.show()
|
|
self.endLabel.show()
|
|
|
|
def itemMode(self):
|
|
self.startLabel.hide()
|
|
self.endLabel.hide()
|
|
|
|
def mouseMoveEventCustom(self, event):
|
|
# 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.parent.parentScore.parentView.mapFromGlobal(posGlobal) #a widget
|
|
#posScene = self.parent.parentScore.parentView.mapToScene(posView)
|
|
#print (posGlobal, posView, posScene, event.scenePos())
|
|
|
|
"""
|
|
#Does not work with zooming.
|
|
if self.cursorPosOnMoveStart:
|
|
delta = QtGui.QCursor.pos() - self.cursorPosOnMoveStart
|
|
new = self.posBeforeMove + delta
|
|
self.setPos(new)
|
|
"""
|
|
if self.cursorPosOnMoveStart:
|
|
self.setPos(event.scenePos())
|
|
super().mouseMoveEvent(event)
|
|
|
|
def mousePressEventCustom(self, event):
|
|
self.posBeforeMove = self.pos()
|
|
self.cursorPosOnMoveStart = QtGui.QCursor.pos()
|
|
self.setBrush(self.color)
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseReleaseEventCustom(self, event):
|
|
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.
|
|
self.posBeforeMove = None
|
|
self.cursorPosOnMoveStart = None
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def contextMenuEvent(self, event):
|
|
if self.startLabel.isVisible():
|
|
listOfLabelsAndFunctions = [
|
|
("edit properties", lambda: BlockPropertiesEdit(self.scene().parentView.mainWindow, staticExportItem = self.staticExportItem)),
|
|
("separator", None),
|
|
#("split here", lambda: self.splitHere(event)),
|
|
("duplicate", lambda: api.duplicateBlock(self.staticExportItem["id"])),
|
|
("create content link", lambda: api.duplicateContentLinkBlock(self.staticExportItem["id"])),
|
|
("unlink", lambda: api.unlinkBlock(self.staticExportItem["id"])),
|
|
("separator", None),
|
|
("join with next block", lambda: api.joinBlockWithNext(self.staticExportItem["id"])),
|
|
("delete block", lambda: api.deleteBlock(self.staticExportItem["id"])),
|
|
("separator", None),
|
|
("append block at the end", lambda: api.appendBlock(self.parent.staticExportItem["id"])),
|
|
]
|
|
callContextMenu(listOfLabelsAndFunctions)
|
|
else:
|
|
super().contextMenuEvent(event)
|
|
|
|
class GuiTrack(QtWidgets.QGraphicsItem):
|
|
"""In opposite to tracks and block(backgrounds and handles) tracks never get recreated.
|
|
Init is called once on creation, not virtually on every update.
|
|
However, the track children, its parts, get deleted and recreated very often.
|
|
|
|
|
|
Order of creation:
|
|
init
|
|
stafflines
|
|
second init
|
|
update edit mode
|
|
-> then api takes over and instructs the following methods through callbacks
|
|
|
|
Typical order of method calls for updates:
|
|
barlines
|
|
beams
|
|
stafflines
|
|
items
|
|
redraw
|
|
background color
|
|
update edit mode (not on every item update, but at least on load)
|
|
"""
|
|
|
|
def __init__(self, parentScore, staticExportItem):
|
|
super().__init__()
|
|
self.parentScore = parentScore
|
|
self.staticExportItem = staticExportItem #This is not the notes but the track meta data. The notes are called staticRepresentationList
|
|
self.items = [] #this is used for stretching and processing of the current items. scene clear is done diffently. See self.createGraphicItemsFromData
|
|
self.barLines = []
|
|
self.beams = []
|
|
self.staffLines = []
|
|
self.staticItems = [] #not deleted automatically by callbacks.
|
|
self.ItemIgnoresTransformations = [] #not literally colors. QRectItems with a color. created in self.paintBlockBackgroundColors()
|
|
self.transparentBlockHandles = [] #list of GuiBlockHandle in order. Only appear when the mouse is used to drag and drop.
|
|
self.backgroundBlockColors = [] #QGraphicsRectItems. Always visible.
|
|
self.lengthInPixel = 0 # a cached value
|
|
self.createStaffLines() #no stafflines at all are too confusing.
|
|
self.ccPaths = {} # ccNumber0-127:PathItem. Empty for a new track. We only create ccPaths with the first ccBlock. Creation and handling is done in GuiScore, starting with syncCCsToBackend.
|
|
|
|
self.nameGraphic = self.NameGraphic(self.staticExportItem["name"], parent = self)
|
|
self.staticItems.append(self.nameGraphic)
|
|
|
|
|
|
|
|
#Add one central "Create new CC Path" button which is for all non-existing CC Paths of this track and reacte to the current constantsAndConfig.ccValue
|
|
#This button is not in the CCPath object because those only get created for existing backend-CCs.
|
|
self.universalCreateFirstCCBlock = QtWidgets.QGraphicsSimpleTextItem("Create CC Path")
|
|
self.universalCreateFirstCCBlock.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity) #toggle between edit modes not only hides stuff but the track itself gets 10% opacity. We want to avoid it for this item.
|
|
self.universalCreateFirstCCBlock.mousePressEvent = lambda mouseEvent: api.newGraphTrackCC(trId = self.staticExportItem["id"], cc = constantsAndConfigs.ccViewValue) #trigger callback to self.syncCCsToBackend
|
|
self.universalCreateFirstCCBlock.setParentItem(self)
|
|
self.universalCreateFirstCCBlock.setPos(0,0) #for now. Afterwards it gets updated by updateBlocks .
|
|
self.universalCreateFirstCCBlock.setZValue(10)
|
|
|
|
#self.secondStageInitNowThatWeHaveAScene gets called by the ScoreScene.redraw(), where new tracks get created. After it was inserted into the scene.
|
|
|
|
class NameGraphic(QtWidgets.QGraphicsSimpleTextItem):
|
|
def __init__(self, text, parent):
|
|
super().__init__(text)
|
|
self.setFlags(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity)
|
|
self.parent = parent
|
|
self.setParentItem(parent)
|
|
|
|
def _editName(self):
|
|
result = QtWidgets.QInputDialog.getText(self.scene().parentView, "Track Name", #dialog title
|
|
"Set Track Name for {}".format(self.parent.staticExportItem["id"]), #label
|
|
QtWidgets.QLineEdit.Normal,
|
|
self.parent.staticExportItem["name"]
|
|
)
|
|
if result[1]:
|
|
api.setTrackName(self.parent.staticExportItem["id"], nameString = result[0], initialInstrumentName = self.parent.staticExportItem["initialInstrumentName"], initialShortInstrumentName = self.parent.staticExportItem["initialShortInstrumentName"]) #keep the old lilypond names
|
|
|
|
def contextMenuEvent(self, event):
|
|
listOfLabelsAndFunctions = [ ("edit name", self._editName), ]
|
|
callContextMenu(listOfLabelsAndFunctions)
|
|
event.accept()
|
|
|
|
def paint(self, *args):
|
|
pass
|
|
|
|
def boundingRect(self, *args):
|
|
return oneRectToReturnThemAll
|
|
|
|
def secondStageInitNowThatWeHaveAScene(self):
|
|
"""ScoreScene.redraw() calls this after the track was inserted into the scene and therefore
|
|
has a position, opacity, parent item etc. (All of that is not read in normal __init__)"""
|
|
pass
|
|
|
|
def toggleNoteheadsRectangles(self):
|
|
for item in self.items:
|
|
item.updateVisibility()
|
|
for beam in self.beams:
|
|
beam.rectangle.setVisible(constantsAndConfigs.noteHeadMode)
|
|
|
|
def redraw(self, staticRepresentationList):
|
|
self.createGraphicItemsFromData(staticRepresentationList)
|
|
self.nameGraphic.setPos(30 + self.lengthInPixel, -1*constantsAndConfigs.stafflineGap) #self.lengthInPixel is now up to date
|
|
|
|
def paintBlockBackgroundColors(self, staticBlocksRepresentation):
|
|
"""This gets not called by self.createGraphicItemsFromData but only by
|
|
a score callback for blocksChanged"""
|
|
for bg in self.backgroundBlockColors:
|
|
self.parentScore.removeWhenIdle(bg)
|
|
for th in self.transparentBlockHandles:
|
|
self.parentScore.removeWhenIdle(th)
|
|
self.backgroundBlockColors = []
|
|
self.transparentBlockHandles = []
|
|
|
|
for block in staticBlocksRepresentation:
|
|
if self.staticExportItem["double"]: #double track, twice as high
|
|
h = 10 * constantsAndConfigs.stafflineGap
|
|
else:
|
|
h = 4 * constantsAndConfigs.stafflineGap
|
|
bgItem = QtWidgets.QGraphicsRectItem(0, 0, block["completeDuration"] / constantsAndConfigs.ticksToPixelRatio, h) #x, y, w, h
|
|
bgItem.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity)
|
|
bgItem.setPen(QtGui.QColor("transparent"))
|
|
color = self.stringToColor(block["name"])
|
|
bgItem.setBrush(color)
|
|
bgItem.setParentItem(self)
|
|
self.backgroundBlockColors.append(bgItem)
|
|
bgItem.setPos(block["tickindex"] / constantsAndConfigs.ticksToPixelRatio, -2*constantsAndConfigs.stafflineGap)
|
|
bgItem.setZValue(-10) #This is the z value within GuiTrack
|
|
bgItem.setEnabled(False)
|
|
|
|
transparentBlockHandle = GuiBlockHandle(self, block, 0, -2 * constantsAndConfigs.stafflineGap, block["completeDuration"] / constantsAndConfigs.ticksToPixelRatio, h + 4 * constantsAndConfigs.stafflineGap) #x, y, w, h
|
|
transparentBlockHandle.color = color
|
|
self.transparentBlockHandles.append(transparentBlockHandle)
|
|
transparentBlockHandle.setPos(block["tickindex"] / constantsAndConfigs.ticksToPixelRatio, -2*constantsAndConfigs.stafflineGap)
|
|
|
|
def createStaffLines(self, lengthInPixel = 0):
|
|
"""By default creates 5 stafflines. But it can be 10;
|
|
5 extra below the origin-staff.
|
|
This is NOT a double-system like a piano but just a staff
|
|
with more lines that happens to have the range of e.g.
|
|
treble + bass clef."""
|
|
|
|
def createLine(yOffset):
|
|
line = QtWidgets.QGraphicsLineItem(QtCore.QLineF(0, 0, lengthInPixel, 0))
|
|
line.setParentItem(self)
|
|
line.setPen(cosmeticPen)
|
|
self.staffLines.append(line)
|
|
line.setPos(0, yOffset*constantsAndConfigs.stafflineGap)
|
|
line.setZValue(-5) #This is the z value within GuiTrack
|
|
|
|
for l in self.staffLines:
|
|
self.parentScore.removeWhenIdle(l)
|
|
self.staffLines = []
|
|
lengthInPixel += 25 #a bonus that gives the hint that you can write after the last object.
|
|
|
|
for i in range(-2, 3): #the normal 5 line system. We have a lower and upper range/position. The middle line is at position 0
|
|
createLine(i)
|
|
|
|
if self.staticExportItem["double"]: #add more stuffs below (user-perspective. positive Qt values)
|
|
for i in range(4, 9): #i is now 3. Make a gap:
|
|
createLine(i)
|
|
|
|
def createBarlines(self, barlinesTickList):
|
|
"""and measure numbers"""
|
|
for bl in self.barLines:
|
|
self.parentScore.removeWhenIdle(bl)
|
|
self.barLines = []
|
|
|
|
if self.staticExportItem["double"]:
|
|
h = 10 * constantsAndConfigs.stafflineGap
|
|
else:
|
|
h = 4 * constantsAndConfigs.stafflineGap
|
|
|
|
#if barlinesTickList[0] == 0: #happens when there is a metrical instruction at tick 0.
|
|
# del barlinesTickList[0]
|
|
|
|
last = None
|
|
offset = 0
|
|
for barnumber, barlineTick in enumerate(barlinesTickList):
|
|
if barlineTick == last:
|
|
offset += 1
|
|
continue #don't draw the double barline
|
|
last = barlineTick
|
|
line = QtWidgets.QGraphicsLineItem(QtCore.QLineF(0, 0, 0, h))
|
|
line.setParentItem(self)
|
|
self.barLines.append(line)
|
|
line.setPos(barlineTick / constantsAndConfigs.ticksToPixelRatio, -2*constantsAndConfigs.stafflineGap)
|
|
|
|
number = QtWidgets.QGraphicsSimpleTextItem(str(barnumber+1-offset))
|
|
number.setScale(0.75)
|
|
number.setParentItem(line)
|
|
number.setPos(-2, -3*constantsAndConfigs.stafflineGap) #-2 on X for a little fine tuning.
|
|
|
|
|
|
|
|
def createBeams(self, beamList):
|
|
for b in self.beams:
|
|
self.parentScore.removeWhenIdle(b)
|
|
self.beams = []
|
|
|
|
for startTick, endTick, beamtype, positionAsStaffline, direction in beamList:
|
|
numberOfBeams = int(log(beamtype, 2)-2)
|
|
assert numberOfBeams == log(beamtype, 2)-2
|
|
for x in range(numberOfBeams):
|
|
|
|
if direction > 0: #stem/beam upwards
|
|
beamNumberOffset = 4*x
|
|
xOffset = 7
|
|
else:
|
|
beamNumberOffset = -4*x
|
|
xOffset = 1
|
|
|
|
|
|
#non-scalable X-Offsets are not possible via setPos. zoom and stretch go haywire.
|
|
#Instead we use one item for strechting and for the offset position and wrap it in an item group that is used for positioing.
|
|
shifterAnchor = QtWidgets.QGraphicsItemGroup()
|
|
shifterAnchor.setParentItem(self)
|
|
|
|
rectangle = QtWidgets.QGraphicsRectItem(0, 0, (endTick-startTick) / constantsAndConfigs.ticksToPixelRatio, constantsAndConfigs.beamHeight) #x, y, w, h
|
|
rectangle.setBrush(QtGui.QColor("black"))
|
|
|
|
shifterAnchor.addToGroup(rectangle)
|
|
shifterAnchor.rectangle = rectangle
|
|
rectangle.setPos(xOffset, 0)
|
|
|
|
if constantsAndConfigs.noteHeadMode:
|
|
rectangle.show()
|
|
else:
|
|
rectangle.hide()
|
|
|
|
self.beams.append(shifterAnchor)
|
|
|
|
x = startTick/ constantsAndConfigs.ticksToPixelRatio
|
|
y = beamNumberOffset + positionAsStaffline * constantsAndConfigs.stafflineGap / 2 - 1
|
|
shifterAnchor.setPos(x, y)
|
|
|
|
class TrackAnchor(QtWidgets.QGraphicsItem):
|
|
"""Handling all items as individuals when deleting a track to redraw it is too much.
|
|
Better let Qt handle it all at once."""
|
|
oneRectToReturnThemAll = QtCore.QRectF(0,0,0,0) #prevent the annoying "NotImplementError" from Qt for boundingRect. For items that don't need any collision detection.
|
|
def __init__(self, parent):
|
|
super().__init__()
|
|
self.parent = parent
|
|
def paint(self, *args):
|
|
pass
|
|
def boundingRect(self, *args):
|
|
return oneRectToReturnThemAll
|
|
|
|
def createGraphicItemsFromData(self, staticRepresentationList):
|
|
"""Create staff objects including simple barlines"""
|
|
self.parentScore.cursor.clearItemHighlight() #or else the current highlight gets deleted while it is on an item
|
|
try:
|
|
self.parentScore.removeWhenIdle(self.anchor)
|
|
except AttributeError: #first round
|
|
pass
|
|
self.items = []
|
|
itemsAppend = self.items.append
|
|
|
|
self.anchor = GuiTrack.TrackAnchor(self)
|
|
self.anchor.setParentItem(self)
|
|
|
|
metaDataDict = staticRepresentationList.pop()
|
|
for staticItem in staticRepresentationList:
|
|
item = staticItem2Item(staticItem)
|
|
item.setParentItem(self.anchor)
|
|
itemsAppend(item)
|
|
item.setPos(item.pixelPosition, 0) # Y axis is set by the position of the track. This sets the position of the whole ItemGroup. The actual pitch of a chord/note is set locally by the chord itself and of no concern here.
|
|
item.setZValue(5) #This is the z value within GuiTrack
|
|
|
|
self.lengthInPixel = metaDataDict["duration"] / constantsAndConfigs.ticksToPixelRatio #this gets updated in stretchXCoordinates
|
|
self.createBarlines(metaDataDict["barlines"])
|
|
self.createBeams(metaDataDict["beams"])
|
|
self.createStaffLines(self.lengthInPixel)
|
|
|
|
|
|
def stretchXCoordinates(self, factor):
|
|
"""Reposition the items on the X axis.
|
|
Call goes through all parents/children, starting from ScoreView._stretchXCoordinates.
|
|
Docstring there."""
|
|
#The rects and lines here are just a QGraphicsRectItem and have no custom subclass. So they have no stretchXCoordinates itself.
|
|
for item in self.items:
|
|
item.setX(item.pos().x() * factor)
|
|
item.stretchXCoordinates(factor)
|
|
|
|
for staticItem in self.staticItems: #labels etc.
|
|
staticItem.setX(staticItem.pos().x() * factor)
|
|
|
|
for barline in self.barLines:
|
|
barline.setX(barline.pos().x() * factor)
|
|
|
|
for staffline in self.staffLines: #stafflines start from x=0. 0*factor=0 so we omit setPos
|
|
stretchLine(staffline, factor)
|
|
|
|
for beam in self.beams: #beams are part of the track, not of the note items.
|
|
beam.setX(beam.pos().x() * factor )
|
|
stretchRect(beam.rectangle, factor)
|
|
|
|
for backgroundBlockColor in self.backgroundBlockColors:
|
|
backgroundBlockColor.setX(backgroundBlockColor.pos().x() * factor)
|
|
stretchRect(backgroundBlockColor, factor)
|
|
|
|
for transparentBlockHandle in self.transparentBlockHandles:
|
|
transparentBlockHandle.setX(transparentBlockHandle.pos().x() * factor)
|
|
transparentBlockHandle.stretchXCoordinates(factor)
|
|
|
|
for ccPath in self.ccPaths.values():
|
|
ccPath.stretchXCoordinates(factor)
|
|
|
|
self.lengthInPixel = self.lengthInPixel * factor #this also gets updated in createGraphicItemsFromData
|
|
|
|
def itemById(self, itemId):
|
|
"""The itemId is the same for backend and gui items. In fact, it is really the
|
|
backend-python-object id. But we exported it as static value."""
|
|
return next(item for item in self.items if item.staticItem["id"] == itemId)
|
|
|
|
def blockAt(self, xScenePosition):
|
|
for th in self.transparentBlockHandles:
|
|
start = th.staticExportItem["tickindex"] / constantsAndConfigs.ticksToPixelRatio
|
|
end = start + th.staticExportItem["completeDuration"] / constantsAndConfigs.ticksToPixelRatio
|
|
if start <= xScenePosition < end:
|
|
return th
|
|
return None #After the last block.
|
|
|
|
def stringToColor(self, st):
|
|
"""Convert a string to QColor. Same string, same color
|
|
Is used for track coloring"""
|
|
if st:
|
|
c = md5(st.encode()).hexdigest()
|
|
return QtGui.QColor(int(c[0:9],16) % 255, int(c[10:19],16) % 255, int(c[20:29],16)% 255, 255)
|
|
else:
|
|
return QtGui.QColor(255,255,255,255) #Return White
|
|
|
|
def updateMode(self, nameAsString):
|
|
"""Modes are opacity based, not show and hide.
|
|
This gives us the option that children can ignore the opacity (via qt-flag).
|
|
For example we need the block backgrounds to stay visible.
|
|
"""
|
|
assert nameAsString in constantsAndConfigs.availableEditModes
|
|
|
|
#We always hide all CC paths and reactivate only a specific one in cc mode later.
|
|
for ccPath in self.ccPaths.values():
|
|
ccPath.hide()
|
|
self.universalCreateFirstCCBlock.hide()
|
|
|
|
if nameAsString == "notation":
|
|
self.setOpacity(1)
|
|
for backgroundColor in self.backgroundBlockColors: #created in self.paintBlockBackgroundColors()
|
|
backgroundColor.setOpacity(0.2)
|
|
for tbh in self.transparentBlockHandles:
|
|
tbh.itemMode()
|
|
|
|
elif nameAsString == "cc":
|
|
self.setOpacity(0.1) #the notes can still be seen in the background.
|
|
if constantsAndConfigs.ccViewValue in self.ccPaths: #It is not guaranteed that all CC Values have content. On the contrary...
|
|
self.ccPaths[constantsAndConfigs.ccViewValue].show()
|
|
else:
|
|
self.universalCreateFirstCCBlock.show()
|
|
for backgroundColor in self.backgroundBlockColors: #created in self.paintBlockBackgroundColors()
|
|
backgroundColor.setOpacity(0.2)
|
|
for tbh in self.transparentBlockHandles:
|
|
tbh.itemMode()
|
|
|
|
elif nameAsString == "block":
|
|
self.setOpacity(0)
|
|
for backgroundColor in self.backgroundBlockColors: #simple QRectItems, they don't have their own updateMode function
|
|
backgroundColor.setOpacity(1)
|
|
for tbh in self.transparentBlockHandles:
|
|
tbh.blockMode()
|
|
|
|
class GuiScore(QtWidgets.QGraphicsScene):
|
|
def __init__(self, parentView):
|
|
super().__init__()
|
|
self.parentView = parentView
|
|
self.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
|
|
|
|
self.tracks = {} #trackId:guiTrack, #if we don't save the instances here in Python space Qt will loose them and they will not be displayed without any error message.
|
|
|
|
self.deleteOnIdleStack = [] # a stack that holds hidden items that need to be deleted. Hiding is much faster than deleting so we use that for the blocking function. Since we always recreate items and never re-use this is ok as a list. no need for a set.
|
|
self._deleteOnIdleLoop = QtCore.QTimer()
|
|
self._deleteOnIdleLoop.start(0) #0 means "if there is time"
|
|
self._deleteOnIdleLoop.timeout.connect(self._deleteOnIdle) #processes deleteOnIdleStack
|
|
|
|
self.duringTrackDragAndDrop = None #switched to a QGraphicsItem (e.g. GuiTrack) while a track is moved around by the mouse
|
|
self.duringBlockDragAndDrop = None #switched to a QGraphicsItem (e.g. GuiTrack) while a block is moved around by the mouse
|
|
|
|
self.conductor = Conductor(parentView = self.parentView)
|
|
self.addItem(self.conductor)
|
|
self.conductor.setPos(0, -1 * self.conductor.totalHeight)
|
|
|
|
self.yStart = self.conductor.y() - self.conductor.totalHeight/2
|
|
|
|
self.hiddenTrackCounter = QtWidgets.QGraphicsSimpleTextItem("") #filled in by self.redraw on callback tracksChanged (loading or toggling visibility of backend tracks)
|
|
self.addItem(self.hiddenTrackCounter)
|
|
|
|
self.backColor = QtGui.QColor()
|
|
self.backColor.setNamedColor("#fdfdff")
|
|
self.setBackgroundBrush(self.backColor)
|
|
|
|
self.grid = GuiGrid(parent=self)
|
|
self.addItem(self.grid)
|
|
self.grid.setPos(0, -20 * constantsAndConfigs.stafflineGap) #this is more calculation than simply using self.yStart, and might require manual adjustment in the future, but at least it guarantees the grid matches the staffline positions
|
|
self.grid.setZValue(-50)
|
|
|
|
self.cachedSceneHeight = 0 #set in self.redraw. Used by updateTrack to set the sceneRect
|
|
|
|
#All Cursors
|
|
self.cursor = Cursor()
|
|
self.addItem(self.cursor)
|
|
self.selection = Selection()
|
|
self.addItem(self.selection)
|
|
self.playhead = Playhead(self)
|
|
self.addItem(self.playhead)
|
|
self.playhead.setY(self.yStart)
|
|
|
|
#Callbacks
|
|
api.callbacks.tracksChanged.append(self.redraw)
|
|
api.callbacks.updateTrack.append(self.updateTrack)
|
|
api.callbacks.updateBlockTrack.append(self.trackPaintBlockBackgroundColors)
|
|
|
|
api.callbacks.updateGraphTrackCC.append(self.updateGraphTrackCC)
|
|
api.callbacks.updateGraphBlockTrack.append(self.updateGraphBlockTrack)
|
|
api.callbacks.graphCCTracksChanged.append(self.syncCCsToBackend)
|
|
|
|
def updateMode(self, nameAsString):
|
|
assert nameAsString in constantsAndConfigs.availableEditModes
|
|
|
|
for track in self.tracks.values():
|
|
track.updateMode(nameAsString)
|
|
|
|
self.grid.updateMode(nameAsString)
|
|
|
|
def maxTrackLength(self):
|
|
if self.tracks:
|
|
return max(tr.lengthInPixel for tr in self.tracks.values())
|
|
#return max(max(tr.lengthInPixel for tr in self.tracks.values()), self.parentView.geometry().width())
|
|
#return max(max(tr.lengthInPixel for tr in self.scene().tracks.values()), self.scene().parentView.geometry().width()) + self.scene().parentView.geometry().width()
|
|
else:
|
|
return 0 #self.parentView.geometry().width()
|
|
|
|
def updateSceneRect(self):
|
|
self.parentView.setSceneRect(QtCore.QRectF(-5, self.yStart, self.maxTrackLength() + 300, self.cachedSceneHeight)) #x,y,w,h
|
|
|
|
def updateTrack(self, trackId, staticRepresentationList):
|
|
"""for callbacks"""
|
|
if trackId in self.tracks:
|
|
self.tracks[trackId].redraw(staticRepresentationList)
|
|
#else:
|
|
#hidden track. But this can still happen through the data editor
|
|
self.parentView.updateMode()
|
|
self.updateSceneRect()
|
|
|
|
def trackPaintBlockBackgroundColors(self, trackId, staticBlocksRepresentation):
|
|
if trackId in self.tracks:
|
|
self.tracks[trackId].paintBlockBackgroundColors(staticBlocksRepresentation)
|
|
#else:
|
|
#hidden track.
|
|
|
|
def updateGraphTrackCC(self, trackId, ccNumber, staticRepresentationList):
|
|
"""TrackId is a real notation track which has a dict of CCs"""
|
|
if trackId in self.tracks:
|
|
self.tracks[trackId].ccPaths[ccNumber].createGraphicItemsFromData(staticRepresentationList)
|
|
|
|
def updateGraphBlockTrack(self, trackId, ccNumber, staticRepresentationList):
|
|
"""TrackId is a real notation track which has a dict of CCs"""
|
|
if trackId in self.tracks:
|
|
self.tracks[trackId].ccPaths[ccNumber].updateGraphBlockTrack(staticRepresentationList)
|
|
|
|
def toggleNoteheadsRectangles(self):
|
|
for track in self.tracks.values():
|
|
track.toggleNoteheadsRectangles()
|
|
|
|
def syncCCsToBackend(self, trackId, listOfCCsInThisTrack):
|
|
"""Delete ccGraphs that are gui-only,
|
|
create gui versions of graphs that are backend-only.
|
|
Don't touch the others.
|
|
|
|
This is the entry point for new CCPaths. They get created
|
|
only here.
|
|
"""
|
|
#TODO: and moved from one CC value to another?
|
|
|
|
guiCCs = set(self.tracks[trackId].ccPaths.keys())
|
|
|
|
for backendCC in listOfCCsInThisTrack:
|
|
if backendCC in guiCCs:
|
|
guiCCs.remove(backendCC) #all right. no update needed.
|
|
else: #new CC. not existent in the Gui yet. Create.
|
|
#This is the place where we create new CCPaths
|
|
new = graphs.CCPath(parentGuiTrack = self.tracks[trackId], parentDataTrackId = trackId)
|
|
self.tracks[trackId].ccPaths[backendCC] = new #store in the GUI Track
|
|
new.setParentItem(self.tracks[trackId])
|
|
new.setPos(0,0)
|
|
#new.setZValue(100)
|
|
|
|
#all items left in the set are GUI-only CCs. which means the backend graphs were deleted. Delete them here as well.
|
|
for cc in guiCCs:
|
|
self.removeWhenIdle(self.tracks[trackId].ccPaths[cc])
|
|
del self.tracks[trackId].ccPaths[cc]
|
|
|
|
|
|
def redraw(self, listOfStaticTrackRepresentations):
|
|
"""The order of guiTracks depends on the backend index.
|
|
This way it is a no-brainer, we don't need to maintain our own
|
|
order, just sync with the backend when the callback comes in.
|
|
|
|
Also handles meta data like track names.
|
|
But not the actual track content, which is done
|
|
through self.updateTrack which has its own api-callback
|
|
|
|
called by callbacksDatabase.tracksChanged"""
|
|
|
|
for track in self.tracks.values():
|
|
track.hide()
|
|
|
|
doubleTrackOffset = 0
|
|
for trackExportObject in listOfStaticTrackRepresentations:
|
|
if not trackExportObject["id"] in self.tracks:
|
|
guiTrack = GuiTrack(self, trackExportObject)
|
|
self.tracks[trackExportObject["id"]] = guiTrack
|
|
self.addItem(guiTrack)
|
|
guiTrack.secondStageInitNowThatWeHaveAScene()
|
|
|
|
self.tracks[trackExportObject["id"]].staticExportItem = trackExportObject
|
|
self.tracks[trackExportObject["id"]].setPos(0, constantsAndConfigs.trackHeight * trackExportObject["index"] + doubleTrackOffset)
|
|
self.tracks[trackExportObject["id"]].setZValue(0) #direct comparison only possible with the grid, which is at -50
|
|
self.tracks[trackExportObject["id"]].nameGraphic.setText(trackExportObject["name"])
|
|
self.tracks[trackExportObject["id"]].show()
|
|
|
|
if trackExportObject["double"]:
|
|
doubleTrackOffset += constantsAndConfigs.trackHeight
|
|
|
|
toDelete = []
|
|
for trackId, track in self.tracks.items():
|
|
if not track.isVisible():
|
|
toDelete.append((trackId, track))
|
|
|
|
for trackId_, track_ in toDelete:
|
|
self.removeWhenIdle(track_)
|
|
del self.tracks[trackId_]
|
|
|
|
#Finally, under the last track, tell the user how many hidden tracks there are
|
|
nrOfHiddenTracks = len(api.session.data.hiddenTracks)
|
|
if nrOfHiddenTracks:
|
|
self.hiddenTrackCounter.setText("… and {} hidden tracks".format(nrOfHiddenTracks))
|
|
else: #remove previous status message
|
|
self.hiddenTrackCounter.setText("")
|
|
|
|
belowLastTrack = constantsAndConfigs.trackHeight * (trackExportObject["index"] + 1) + doubleTrackOffset
|
|
self.hiddenTrackCounter.setPos(5, belowLastTrack)
|
|
self.cachedSceneHeight = belowLastTrack + constantsAndConfigs.trackHeight
|
|
|
|
def removeWhenIdle(self, item):
|
|
"""Call this function instead of removeItem. You are responsible to delete the item from any
|
|
list or other container where it was stored in the meantime yourself.
|
|
|
|
Other methods tried:
|
|
-removeItem much slower
|
|
-create second scene and do scene2.addItem to get it out here - same as removeItem, if not slower
|
|
-delete a track and recreate a new one - same as all removeItems
|
|
-just hide the track and create a new one. slightly slower as hiding just the items.
|
|
-item.setFlag(QtWidgets.QGraphicsItem.ItemHasNoContents) slow, but not as slow as removeItem
|
|
-just hide items, never delete them is fast but gets slower the more items are hidden in the track
|
|
"""
|
|
item.hide()
|
|
self.deleteOnIdleStack.append(item) #This will actually delete the item and remove it from the scene when there is idle time
|
|
|
|
def _deleteOnIdle(self):
|
|
"""Hiding a QGraphicsItem is much faster than removing it from the scene. To keep the
|
|
GUI responsive but also to get rid of the old data we hide in createGraphicItemsFromData
|
|
and whenever there is time the actual removing and deleting will happen here.
|
|
|
|
This function is connected to a timer(0) defined in self init.
|
|
"""
|
|
if self.deleteOnIdleStack:
|
|
deleteMe = self.deleteOnIdleStack.pop()
|
|
self.removeItem(deleteMe) #This is the only line in the program that should call scene.removeItem
|
|
del deleteMe
|
|
|
|
|
|
def trackAt(self, qScenePosition):
|
|
"""trackAt always returns the full GuiTrack, even if in ccEdit mode."""
|
|
|
|
if qScenePosition.y() < self.conductor.y() + self.conductor.totalHeight/4:
|
|
return self.conductor
|
|
|
|
for guiTrack in sorted(self.tracks.values(), key = lambda gT: gT.y()):
|
|
if guiTrack.y() >= qScenePosition.y() - constantsAndConfigs.trackHeight/2:
|
|
return guiTrack
|
|
else:
|
|
return None #no track here.
|
|
|
|
def blockAt(self, qScenePosition):
|
|
track = self.trackAt(qScenePosition)
|
|
if track is self.conductor:
|
|
return self.conductor.blockAt(qScenePosition.x())
|
|
if self.parentView.mode() in ("block", "notation"):
|
|
if track:
|
|
return track.blockAt(qScenePosition.x())
|
|
elif self.parentView.mode() == "cc":
|
|
if track and constantsAndConfigs.ccViewValue in track.ccPaths:
|
|
return track.ccPaths[constantsAndConfigs.ccViewValue].blockAt(qScenePosition.x())
|
|
else:
|
|
raise NotImplementedError
|
|
return None
|
|
|
|
def stretchXCoordinates(self, factor):
|
|
"""Reposition the items on the X axis.
|
|
Call goes through all parents/children, starting from ScoreView._stretchXCoordinates.
|
|
Docstring there."""
|
|
#The big structures have a fixed position at (0,0) and move its child items, like notes, internally
|
|
#Some items, like the cursor, move around, as a whole item, in the scene directly and need no stretchXCoordinates() themselves.
|
|
self.grid.stretchXCoordinates(factor)
|
|
self.conductor.stretchXCoordinates(factor)
|
|
self.cursor.setX(self.cursor.pos().x() * factor)
|
|
self.playhead.setX(self.playhead.pos().x() * factor)
|
|
|
|
for track in self.tracks.values():
|
|
track.stretchXCoordinates(factor)
|
|
|
|
self.updateSceneRect()
|
|
|
|
#Macro-Structure: Score / Track / Block Moving and Duplicating
|
|
#Hold the ALT Key to unlock the moving mode.super().keyPressEvent(event)
|
|
#No note-editing requires a mouse action, so the mouse is free for controlling other aspects.
|
|
#Like zooming or moving blocks around.
|
|
|
|
"""
|
|
def keyPressEvent(self, event):
|
|
#Triggers only if there is no shortcut for an action.
|
|
modifiers = QtWidgets.QApplication.keyboardModifiers()
|
|
if modifiers == QtCore.Qt.AltModifier:
|
|
Alt
|
|
|
|
super().keyPressEvent(event)
|
|
"""
|
|
|
|
def mousePressEvent(self, event):
|
|
"""Pressing the mouse button is the first action of drag
|
|
and drop. We make the mouse cursor invisible so the user
|
|
can see where the point is going
|
|
|
|
When in blockmode pressing the middle button combined with either Alt or Shift moves tracks and blocks.
|
|
|
|
"""
|
|
if event.button() == 4 and self.parentView.mode() == "block": # Middle Button
|
|
modifiers = QtWidgets.QApplication.keyboardModifiers()
|
|
if modifiers == QtCore.Qt.ShiftModifier: #block move
|
|
block = self.blockAt(event.scenePos())
|
|
if block: #works for note blocks and conductor blocks
|
|
block.staticExportItem["guiPosStart"] = block.pos() #without shame we hijack the backend-dict.
|
|
self.duringBlockDragAndDrop = block
|
|
block.mousePressEventCustom(event)
|
|
|
|
elif modifiers == QtCore.Qt.AltModifier and self.parentView.mode() == "block": #track move
|
|
track = self.trackAt(event.scenePos())
|
|
if track and not track is self.conductor:
|
|
self.parentView.setCursor(QtCore.Qt.BlankCursor)
|
|
self.cursor.hide()
|
|
track.staticExportItem["guiPosStart"] = track.pos()
|
|
self.duringTrackDragAndDrop = track
|
|
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseMoveEvent(self, event):
|
|
"""Catches certain mouse events for moving tracks and blocks.
|
|
Otherwise the event is propagated to the real QGraphicsItem.
|
|
Don't forget that an item needs to have the flag movable or selectable or else
|
|
it will not get mouseRelease or mouseMove events. MousePress always works."""
|
|
|
|
if self.duringTrackDragAndDrop:
|
|
x = self.duringTrackDragAndDrop.staticExportItem["guiPosStart"].x()
|
|
y = event.scenePos().y()
|
|
self.duringTrackDragAndDrop.setPos(x, y)
|
|
elif self.duringBlockDragAndDrop:
|
|
self.duringBlockDragAndDrop.mouseMoveEventCustom(event)
|
|
|
|
super().mouseMoveEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
"""Catches certain mouse events for moving tracks and blocks.
|
|
Otherwise the event is propagated to the real QGraphicsItem.
|
|
Don't forget that an item needs to have the flag movable or selectable or else
|
|
it will not get mouseRelease or mouseMove events. MousePress always works."""
|
|
|
|
self.parentView.unsetCursor() #While moving stuff the mouse-cursor is hidden. Reset.
|
|
#self.cursor.show() #Our own position cursor #TODO: why was that in here? It shows the cursor after a mouseclick, even in CC mode (it should not)
|
|
|
|
tempBlockDragAndDrop = self.duringBlockDragAndDrop
|
|
tempTrackDragAndDrop = self.duringTrackDragAndDrop
|
|
self.duringBlockDragAndDrop = None
|
|
self.duringTrackDragAndDrop = None
|
|
#Now we can exit the function safely at any time without the need to reset the dragAndDrop storage in different places
|
|
|
|
if tempTrackDragAndDrop: #This is only for GuiTrack aka note tracks. CC tracks follow the gui track and the conductor cannot be moved.
|
|
assert not tempBlockDragAndDrop
|
|
assert type(tempTrackDragAndDrop) is GuiTrack
|
|
dragTrackPosition = tempTrackDragAndDrop.pos().y()
|
|
trackPositions = sorted([guiTrack.pos().y() for id, guiTrack in self.tracks.items()])
|
|
trackPositions.remove(dragTrackPosition)
|
|
|
|
listOfTrackIds = api.session.data.asListOfTrackIds()
|
|
listOfTrackIds.remove(tempTrackDragAndDrop.staticExportItem["id"])
|
|
|
|
#Calculate the new track position and trigger a redraw
|
|
canditateForNewTrackPosition = 0 #this takes care of a score with only one track and also if you move a track to the 0th position
|
|
for y in trackPositions:
|
|
if dragTrackPosition >= y:
|
|
canditateForNewTrackPosition = trackPositions.index(y) +1
|
|
|
|
listOfTrackIds.insert(canditateForNewTrackPosition, tempTrackDragAndDrop.staticExportItem["id"])
|
|
if len(listOfTrackIds) == 1:
|
|
tempTrackDragAndDrop.setPos(tempTrackDragAndDrop.staticExportItem["guiPosStart"]) #no need to trigger an api call with undo history etc.
|
|
else:
|
|
api.rearrangeTracks(listOfTrackIds)
|
|
|
|
elif tempBlockDragAndDrop: #CC blocks, note blocks and conductor blocks
|
|
assert not tempTrackDragAndDrop
|
|
tempBlockDragAndDrop.mouseReleaseEventCustom(event)
|
|
targetTrack = self.trackAt(event.scenePos()) #this ALWAYS returns a Conductor, GuiTrack or None. Not a CC sub-track. for CC see below when we test the block type and change this variable.
|
|
targetBlock = self.blockAt(event.scenePos())
|
|
dragBlockId = tempBlockDragAndDrop.staticExportItem["id"]
|
|
|
|
#First some basic checks:
|
|
if not targetTrack: #Only drag and drop into tracks.
|
|
return None
|
|
if targetBlock is tempBlockDragAndDrop: #block got moved on itself
|
|
return None
|
|
|
|
|
|
#Now check what kind of block moving we are dealing with
|
|
#If the drag and drop mixes different block/track types we exit
|
|
blockType = type(tempBlockDragAndDrop)
|
|
conductorBlock = noteBlock = ccBlock = False
|
|
|
|
if blockType is GuiBlockHandle:
|
|
if type(targetTrack) is GuiTrack and self.parentView.mode() in ("block", "notation"):
|
|
noteBlock = True
|
|
else: #Drag and Drop between different track types.
|
|
return None
|
|
|
|
elif blockType is ConductorTransparentBlock:
|
|
if targetTrack is self.conductor:
|
|
conductorBlock = True
|
|
else: #Drag and Drop between different track types.
|
|
return None
|
|
|
|
elif blockType is CCGraphTransparentBlock:
|
|
if (not type(targetTrack) is GuiTrack) or (not self.parentView.mode() == "cc"):
|
|
return None
|
|
|
|
ccBlock = True
|
|
if constantsAndConfigs.ccViewValue in targetTrack.ccPaths: #this is only the backend database of CCs in tracks but it should be in sync.
|
|
targetTrackId = targetTrack.staticExportItem["id"] #we need this later. save it before changing the targetBlock variable
|
|
targetTrack = targetTrack.ccPaths[constantsAndConfigs.ccViewValue]
|
|
else:
|
|
return None #TODO: Create a new CC sub-track with the moved block as first block. Mainly a backend call. Needs a call at the end of this function as well.
|
|
|
|
else:
|
|
raise TypeError("Block must be a conductor, note or CC type but is {}".format(blockType))
|
|
|
|
#We now have a track and the track type matches the block type.
|
|
#Find the position to insert.
|
|
if targetBlock is None: #behind the last block
|
|
positionToInsert = len(targetTrack.transparentBlockHandles) #essentially we want append() but insert() is compatible with all types of operation here. len is based 1, so len results in "after the last one" position.
|
|
else:
|
|
assert type(targetBlock) is blockType
|
|
positionToInsert = targetTrack.transparentBlockHandles.index(targetBlock)
|
|
assert positionToInsert >= 0
|
|
|
|
#Create the new order by putting the old block into a new slot and removing it from its old position
|
|
newBlockOrder = [guiBlock.staticExportItem["id"] for guiBlock in targetTrack.transparentBlockHandles]
|
|
if targetTrack == tempBlockDragAndDrop.parent:
|
|
newBlockOrder.remove(dragBlockId) #block will be at another position and removed from its old one.
|
|
newBlockOrder.insert(positionToInsert, dragBlockId)
|
|
|
|
#Finally call the appropriate backend function which will trigger a GuiUpdate.
|
|
if targetTrack is tempBlockDragAndDrop.parent: #Same track or within the conductor track
|
|
if conductorBlock:
|
|
assert targetTrack is self.conductor
|
|
api.rearrangeTempoBlocks(newBlockOrder)
|
|
elif noteBlock:
|
|
api.rearrangeBlocks(targetTrack.staticExportItem["id"], newBlockOrder)
|
|
elif ccBlock:
|
|
api.rearrangeCCBlocks(targetTrackId, constantsAndConfigs.ccViewValue, newBlockOrder)
|
|
#else is already catched by a Else-TypeError above
|
|
else: #Different Track,
|
|
if conductorBlock or targetTrack is self.conductor:
|
|
raise RuntimeError("How did this slip through? Checking for cross-track incompatibility was already done above")
|
|
elif noteBlock:
|
|
api.moveBlockToOtherTrack(dragBlockId, targetTrack.staticExportItem["id"], newBlockOrder)
|
|
elif ccBlock: #TODO: Also different CC in the same GuiTrack
|
|
api.moveCCBlockToOtherTrack(dragBlockId, targetTrack.parentDataTrackId, newBlockOrder)
|
|
|
|
elif event.button() == 1: #a positional mouse left click in a note-track
|
|
track = self.trackAt(event.scenePos())
|
|
if track and not track is self.conductor:
|
|
modifiers = QtWidgets.QApplication.keyboardModifiers()
|
|
trackId = track.staticExportItem["id"]
|
|
if modifiers == QtCore.Qt.ShiftModifier:
|
|
api.selectToTickindex(trackId, event.scenePos().x() * constantsAndConfigs.ticksToPixelRatio)
|
|
else:
|
|
api.toTickindex(trackId, event.scenePos().x() * constantsAndConfigs.ticksToPixelRatio)
|
|
|
|
super().mouseReleaseEvent(event)
|
|
|
|
class GuiGrid(QtWidgets.QGraphicsItemGroup):
|
|
"""The grid consists of vertical and horizontal lines.
|
|
Horiontal lines help to eyeball what pitch the cursor is on, vertical help to eyeball
|
|
the rhythm or tick positions e.g. when trying to find the right place for a tempo change or CC.
|
|
|
|
Horizontal lines have always a more-than-enough width and X position.
|
|
Vertical lines are always at the top of the screen and reach down more-than-enough.
|
|
|
|
We only need to take care that there are enough lines, not about their dimensions or positions.
|
|
|
|
Never is a line deleted, only new lines are added if new tracks are added or the musics overall
|
|
duration increases.
|
|
|
|
Also GraphicsView resize event and zooming out adds new lines.
|
|
|
|
A complete clean and redraw only happens when the tick rhythm changes.
|
|
"""
|
|
|
|
def __init__(self, parent):
|
|
super(GuiGrid, self).__init__()
|
|
self.parent = parent #QGraphicsScene
|
|
|
|
self.initialGridExists = False
|
|
self.gapVertical = None #gets recalculated if gridRhythm is changed by the user, or through stretching.
|
|
self.gapHorizontal = None #is constant, but for symetry reasons this is put into redrawTickGrid as well
|
|
|
|
self.width = None #recalculated in reactToresizeEventOrZoom
|
|
self.height = None #recalculated in reactToresizeEventOrZoom
|
|
|
|
self.linesHorizontal = [] #later the grid lines will be stored here
|
|
self.linesVertical = [] #later the grid lines will be stored here
|
|
|
|
self.horizontalScrollbarWaitForGapJump = 0
|
|
self.oldHorizontalValue = 0
|
|
|
|
self.verticalScrollbarWaitForGapJump = 0
|
|
self.oldVerticalValue = 0
|
|
|
|
self.parent.parentView.verticalScrollBar().valueChanged.connect(self.reactToVerticalScroll)
|
|
self.parent.parentView.horizontalScrollBar().valueChanged.connect(self.reactToHorizontalScroll)
|
|
|
|
self.setOpacity(constantsAndConfigs.gridOpacity)
|
|
|
|
gridPen = QtGui.QPen(QtCore.Qt.DotLine)
|
|
gridPen.setCosmetic(True)
|
|
|
|
def reactToHorizontalScroll(self, value):
|
|
if not self.initialGridExists:
|
|
return
|
|
|
|
#Keep horizontal lines in view
|
|
leftBorderAsScenePos = self.parent.parentView.mapToScene(0, 0).x()
|
|
for hline in self.linesHorizontal:
|
|
hline.setX(leftBorderAsScenePos)
|
|
|
|
#Shift vertical lines to new positions, respecting the grid steps
|
|
delta = value - self.oldHorizontalValue #in pixel. positive=right, negative=left. the higher the number the faster the scrolling.
|
|
delta *= 1/constantsAndConfigs.zoomFactor
|
|
self.horizontalScrollbarWaitForGapJump += delta
|
|
self.oldHorizontalValue = value
|
|
if abs(self.horizontalScrollbarWaitForGapJump) > self.gapVertical: #collect scrollpixels until we scrolled more than one gap
|
|
gapMultiplier, rest = divmod(self.horizontalScrollbarWaitForGapJump, self.gapVertical) #really fast scrolling can jump more than one gap
|
|
for vline in self.linesVertical:
|
|
vline.setX(vline.x() + gapMultiplier*self.gapVertical)
|
|
self.horizontalScrollbarWaitForGapJump = rest #keep the rest for the next scroll
|
|
assert abs(self.horizontalScrollbarWaitForGapJump) < self.gapVertical
|
|
|
|
def reactToVerticalScroll(self, value):
|
|
if not self.initialGridExists:
|
|
return
|
|
|
|
#Keep vertical lines in view
|
|
topBorderAsScenePos = self.parent.parentView.mapToScene(0, 0).y()
|
|
for vline in self.linesVertical:
|
|
vline.setY(topBorderAsScenePos)
|
|
|
|
#Shift horizontal lines to a new position, respecting the staffline gap
|
|
delta = value - self.oldVerticalValue #in pixel. positive=down, negative=up. the higher the number the faster the scrolling.
|
|
delta *= 1/constantsAndConfigs.zoomFactor
|
|
self.verticalScrollbarWaitForGapJump += delta
|
|
self.oldVerticalValue = value
|
|
if abs(self.verticalScrollbarWaitForGapJump) > self.gapHorizontal: #collect scrollpixels until we scrolled more than one gap
|
|
gapMultiplier, rest = divmod(self.verticalScrollbarWaitForGapJump, self.gapHorizontal) #really fast scrolling can jump more than one gap
|
|
for hline in self.linesHorizontal:
|
|
hline.setY(hline.y() + gapMultiplier*self.gapHorizontal)
|
|
self.verticalScrollbarWaitForGapJump = rest #keep the rest for the next scroll
|
|
assert abs(self.verticalScrollbarWaitForGapJump) < self.gapHorizontal
|
|
|
|
|
|
def reactToresizeEventOrZoom(self):
|
|
"""Called by the Views resizeEvent.
|
|
When the views geometry changes or zooms we may need to create more lines.
|
|
Never delete and never make smaller though."""
|
|
scoreViewWidgetHeight = self.parent.parentView.height()
|
|
scoreViewWidgetWidth = self.parent.parentView.width()
|
|
|
|
if (not self.height) or scoreViewWidgetHeight * 1.1 > self.height:
|
|
self.height = scoreViewWidgetHeight * 1.1
|
|
if (not self.width) or scoreViewWidgetWidth * 1.1 > self.width:
|
|
self.width = scoreViewWidgetWidth * 1.1
|
|
|
|
if self.initialGridExists:
|
|
assert self.linesHorizontal and self.linesVertical, (self.linesHorizontal, self.linesVertical)
|
|
assert self.parent.parentView.isVisible(), self.parent.parentView.isVisible()
|
|
self._fillVerticalLines()
|
|
self._fillHorizontalLines()
|
|
#else:
|
|
#reactToresizeEventOrZoom without grid
|
|
#self.redrawTickGrid() #this happens only once, on program start. Afterwards it's user triggered by changing the grid rhythm
|
|
|
|
def _fillVerticalLines(self):
|
|
"""Only allowed to get called by reactToresizeEventOrZoom because we need an updated
|
|
self.height value"""
|
|
#Check if we need longer lines. Do this before creating new lines, because new lines
|
|
#have the heighest
|
|
oldHeight = self.linesVertical[-1].line().y2() #this is a bit less than .length() so we use that
|
|
if self.height > oldHeight:
|
|
for vline in self.linesVertical:
|
|
line = vline.line()
|
|
line.setLength(self.height)
|
|
vline.setLine(line)
|
|
|
|
#Check if we need new lines
|
|
newLineCount = int(self.width / self.gapVertical)
|
|
if newLineCount > self.nrOfVerticalLines:
|
|
newLineCount += int(newLineCount - self.nrOfVerticalLines)
|
|
self._createVerticalLines(start=self.nrOfVerticalLines, end=newLineCount) #This draws only yet nonexisting lines.
|
|
self.nrOfVerticalLines = newLineCount #for next time
|
|
|
|
def _fillHorizontalLines(self):
|
|
"""Only allowed to get called by reactToresizeEventOrZoom because we need an updated
|
|
self.width value.
|
|
To be honest.. this is also called by stretchXCoordinates when shrinking the display so
|
|
we need more lines. That does not depend on self.width but on the newLineCount """
|
|
#Check if we need longer lines. Do this before creating new lines, because new lines
|
|
#have the heighest
|
|
oldWidth = self.linesHorizontal[-1].line().x2() #this is a bit less than .length() so we use that
|
|
if self.width > oldWidth:
|
|
for hline in self.linesHorizontal:
|
|
line = hline.line()
|
|
line.setLength(self.width)
|
|
hline.setLine(line)
|
|
|
|
#Check if we need new lines
|
|
newLineCount = int(self.height / self.gapHorizontal)
|
|
if newLineCount > self.nrOfHorizontalLines:
|
|
newLineCount += int(newLineCount - self.nrOfHorizontalLines)
|
|
self._createHorizontalLines(start=self.nrOfHorizontalLines, end=newLineCount) #This draws only yet nonexisting lines.
|
|
self.nrOfHorizontalLines = newLineCount #for next time
|
|
|
|
def stretchXCoordinates(self, factor):
|
|
"""Reposition the items on the X axis.
|
|
Call goes through all parents/children, starting from ScoreView._stretchXCoordinates.
|
|
Docstring there."""
|
|
self.gapVertical = constantsAndConfigs.gridRhythm / constantsAndConfigs.ticksToPixelRatio #this is necessarry every time after stretching (ticksToPixelRatio) and after changing the gridRhythm
|
|
|
|
for vline in self.linesVertical:
|
|
vline.setX(vline.x() * factor)
|
|
|
|
self._fillVerticalLines()
|
|
|
|
#for hline in self.linesHorizontal:
|
|
# stretchLine(hline, factor)
|
|
|
|
def updateMode(self, nameAsString):
|
|
assert nameAsString in constantsAndConfigs.availableEditModes
|
|
if nameAsString == "block":
|
|
for l in self.linesVertical:
|
|
l.hide()
|
|
else:
|
|
for l in self.linesVertical:
|
|
l.show()
|
|
|
|
def _createVerticalLines(self, start, end):
|
|
"""Lines get an offset that matches the grid-rythm.
|
|
So we get more draw area left, if the sceneRect will move into that area for some reason"""
|
|
for i in range(start, end): #range includes the first value but not the end. We know that and all functions in GuiGrid are designed accordingly. For example reactToLongerDuration starts on the highest value of the old number of lines since that was never drawn in the last round.
|
|
vline = self.parent.addLine(0, -5*self.gapHorizontal, 0, self.height, GuiGrid.gridPen) #x1, y1, x2, y2, pen
|
|
self.addToGroup(vline) #first add to group, then set pos
|
|
vline.setPos(i * self.gapVertical, 0)
|
|
vline.setEnabled(False)
|
|
self.linesVertical.append(vline)
|
|
|
|
def _createHorizontalLines(self, start, end):
|
|
"""Lines get an offset that matches the pitch and staff-lines
|
|
So we get more draw area on top, if the sceneRect will move into that area for some reason"""
|
|
for i in range(start, end): #range includes the first value but not the end. We know that and all functions in GuiGrid are designed accordingly. For example reactToMoreTracks starts on the highest value of the old number of lines since that was never drawn in the last round.
|
|
hline = self.parent.addLine(0, 0, self.width, 0, GuiGrid.gridPen) #x1, y1, x2, y2, pen
|
|
self.addToGroup(hline) #first add to group, then set pos
|
|
hline.setPos(0, i * self.gapHorizontal)
|
|
hline.setEnabled(False)
|
|
self.linesHorizontal.append(hline)
|
|
|
|
def redrawTickGrid(self):
|
|
"""A complete redraw.
|
|
This gets called once after the main window gets shown. (in init main window).
|
|
After that only called after the used changes the tick rhythm manually.
|
|
"""
|
|
assert self.parent.parentView.isVisible()
|
|
|
|
if self.initialGridExists:
|
|
#it is possible that the grid did not change. proceed nevertheless
|
|
for l in self.linesHorizontal + self.linesVertical:
|
|
self.parent.removeWhenIdle(l)
|
|
|
|
|
|
self.linesHorizontal = [] #like the staff lines
|
|
self.linesVertical = [] #makes comparing tick positions easier by eye
|
|
|
|
self.gapVertical = constantsAndConfigs.gridRhythm / constantsAndConfigs.ticksToPixelRatio #this is necessarry every time after stretching (ticksToPixelRatio) and after changing the gridRhythm
|
|
self.gapHorizontal = constantsAndConfigs.stafflineGap #never changes
|
|
|
|
self.nrOfHorizontalLines = int(self.height / self.gapHorizontal) #we save that value if the score grows and we need more lines. Initially we use the screen size, not the content.
|
|
self.nrOfVerticalLines = int(self.width / self.gapVertical) #we save that value if the score grows and we need more lines. Initially we use the screen size, not the content.
|
|
|
|
self._createVerticalLines(0, self.nrOfVerticalLines)
|
|
self._createHorizontalLines(0, self.nrOfHorizontalLines)
|
|
|
|
self.initialGridExists = True
|
|
|