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.

623 lines
32 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
Copyright 2022, Nils Hilbricht, Germany ( )
This file is part of the Laborejo Software Suite ( ),
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
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")
#Standard Library
#Third party
from PyQt5 import QtCore, QtGui, QtWidgets
translate = QtCore.QCoreApplication.translate
#Our own files
import engine.api as api
from .constantsAndConfigs import constantsAndConfigs
from .grid import GuiGrid
from .conductor import Conductor, ConductorTransparentBlock
from .musicstructures import GuiBlockHandle, GuiTrack, sceneXOffsetForInitialSigs
from .graphs import CCGraphTransparentBlock, CCPath
from .cursor import Cursor, Playhead, Selection
class GuiScore(QtWidgets.QGraphicsScene):
def __init__(self, parentView):
self.parentView = parentView
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 = set() # 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.
self._deleteOnIdleLoop = QtCore.QTimer()
self._deleteOnIdleLoop.start(100) #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.mouseMoveEventBlocked = False #mouseMoveEvent and helper functions
self.conductor = Conductor(parentView = self.parentView)
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.backColor = QtGui.QColor()
self.cachedSceneHeight = 0 #set in self.redraw. Used by updateTrack to set the sceneRect
self.grid = GuiGrid(parentScene=self)
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
#All Cursors
self.cursor = Cursor()
self.selection = Selection()
self.playhead = Playhead(self)
def updateMode(self, nameAsString):
assert nameAsString in constantsAndConfigs.availableEditModes
for track in self.tracks.values():
self.reactToHorizontalScroll(0) #once force all block mode track names into the center. We cannot do this in init because the tracks are not ready by then. This assumes we always start the program in notation mode
def updateModeSingleTrackRedraw(self, nameAsString:str, trackId:int, trackExport:tuple):
"""trackExport is a tuple of block export dicts"""
if trackId in self.tracks: #is visible?
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()
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
self.parentView.setSceneRect(QtCore.QRectF(sceneXOffsetForInitialSigs, self.yStart, self.maxTrackLength() + 300 + abs(sceneXOffsetForInitialSigs), self.cachedSceneHeight)) #x,y,w,h
def updateTrack(self, trackId, staticRepresentationList):
"""for callbacks"""
if trackId in self.tracks:
#hidden track. But this can still happen through the data editor
def trackPaintBlockBackgroundColors(self, trackId, staticBlocksRepresentation):
if trackId in self.tracks:
#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:
track = self.tracks[trackId]
ccPath = track.ccPaths[ccNumber]
def updateGraphBlockTrack(self, trackId, ccNumber, staticRepresentationList):
"""TrackId is a real notation track which has a dict of CCs"""
if trackId in self.tracks:
def toggleNoteheadsRectangles(self):
for track in self.tracks.values():
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 = CCPath(parentGuiTrack = self.tracks[trackId], parentDataTrackId = trackId)
self.tracks[trackId].ccPaths[backendCC] = new #store in the GUI Track
#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:
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():
doubleTrackOffset = 0
for trackExportObject in listOfStaticTrackRepresentations:
if not trackExportObject["id"] in self.tracks:
guiTrack = GuiTrack(self, trackExportObject)
self.tracks[trackExportObject["id"]] = guiTrack
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
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:
del self.tracks[trackId_]
#Finally, under the last track, tell the user how many hidden tracks there are
nrOfHiddenTracks = len(
if nrOfHiddenTracks:
self.hiddenTrackCounter.setText(translate("scorescene", "… and {} hidden tracks").format(nrOfHiddenTracks))
else: #remove previous status message
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
self.deleteOnIdleStack.add(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 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
for deleteMe in self.deleteOnIdleStack:
self.removeItem(deleteMe) #This is the only line in the program that should call scene.removeItem
self.deleteOnIdleStack = set()
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
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())
raise NotImplementedError
return None
def notTrueAnymore_wheelEvent(self, event):
This docstring was either wrong in the first place or something changed in my code.
However, we do not need to eat wheelEvent here anymore. On the contrary: It will block
items, like the tempoItem from receiving wheelEvents.
The commented out if/else for item detection also has a chance to work, but we don't need
to test for that at all. Standard Qt-behavoiour is fine.
What follows is the old docstring, for legacy documentation reasons:
We MUST handle the event somehow. Otherwise background grid items will block the views(!)
wheel scrolling, even when disabled and setting accepting mouse events to none.
This is a qt bug that won't be fixed because API stability over correctnes (according to the
Contrary to other parts of the system event.ignore and accept actually mean something.
ignore will tell the caller to use the event itself, e.g. scroll.
This event gets the wheel BEFORE the main window (zoom)
#item = self.itemAt(event.scenePos(), self.parentView.transform())
#if type(item) is items.Note:
#super().wheelEvent(event) #send to child item
event.ignore() #so the view scrolls or we zoom
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.cursor.setX(self.cursor.pos().x() * factor)
self.playhead.setX(self.playhead.pos().x() * factor)
for track in self.tracks.values():
#Macro-Structure: Score / Track / Block Moving and Duplicating
def keyPressEvent(self, event):
#Triggers only if there is no shortcut for an action.
modifiers = QtWidgets.QApplication.keyboardModifiers()
if modifiers == QtCore.Qt.AltModifier:
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 object is moving
if self.parentView.mode() == "block": #CC Block Moving is done in its own if clause below.
if event.button() == QtCore.Qt.LeftButton:
modifiers = QtWidgets.QApplication.keyboardModifiers()
#Block Move
if modifiers == QtCore.Qt.NoModifier:
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
#Track Move
elif (modifiers == QtCore.Qt.AltModifier or modifiers == QtCore.Qt.ShiftModifier ) and self.parentView.mode() == "block":
track = self.trackAt(event.scenePos())
if track and not track is self.conductor:
track.staticExportItem["guiPosStart"] = track.pos()
self.duringTrackDragAndDrop = track
elif event.button() == QtCore.Qt.RightButton:
"""Fake context menu event. The built-in one never got
the correct detection. Yes, this will hardcode the
context menu to right mouse button, which is not really
the only way to trigger a context menu. However, too
much time has been spent to get this right already.
Good enough is better than good."""
block = self.blockAt(event.scenePos())
if block: #works for note blocks and conductor blocks
event.accept() #eat it
#CC is a special case because we need to handle points and block movement at the same time.
#Hence the alt/shift key is mandatory here. That was also the reason why everything was with the middle mouse button AND shortcuts once,
#but that was too inconvenient overall. This system is not 100% straightforward but easy enough to remember. And more robust against accidents.
elif self.parentView.mode() == "cc" and event.button() == QtCore.Qt.LeftButton:
modifiers = QtWidgets.QApplication.keyboardModifiers()
if (modifiers == QtCore.Qt.AltModifier or modifiers == QtCore.Qt.ShiftModifier):
block = self.blockAt(event.scenePos())
if block and type(block) is CCGraphTransparentBlock:
block.staticExportItem["guiPosStart"] = block.pos() #without shame we hijack the backend-dict.
self.duringBlockDragAndDrop = block
event.accept() #eat it
else: #CC mode but no modifiers. Add or move item.
#implicit qt scene click detection was unreliable. In the end the manual route was the best option:
block = self.blockAt(event.scenePos())
if block and type(block) is CCGraphTransparentBlock: #We are within the boundaries of a block
super().mousePressEvent(event) #Apparently we need the double event call. Without this we select the wrong items, but not all the time. The result is not being able to move the cc user point. Just leave it in. Solving this "properly" would need refactoring all mouse events.
if block.parentCCPath.hoveringOverItem:
#this is maybe a CC User Item or block end marker. don't handle here, let the item handle it.
#Empty Block click
def _mmE_ensureCursorVisible(self, event):
"""Helper function for mouseMoveEvent.
centerOn and ensureVisible either crash or they recurse forever or they scroll exponentially
We must do it ourselves. Thanks, qt."""
if self.mouseMoveEventBlocked:
return #block the mouseMoveEvent recursion signal. If we scroll it sees the mouse as moving.
currentlyVisibleSceneRect = self.parentView.mapToScene(self.parentView.viewport().geometry()).boundingRect() #x, y top left corner, width, height
leftEdge = int(currentlyVisibleSceneRect.x())
rightEdge = int(leftEdge + currentlyVisibleSceneRect.width())
topEdge = int(currentlyVisibleSceneRect.y())
bottomEdge = int(topEdge + currentlyVisibleSceneRect.height())
x = int(event.scenePos().x())
y = int(event.scenePos().y())
if x < leftEdge:
self.mouseMoveEventBlocked = True #block the mouseMoveEvent recursion signal. If we scroll it sees the mouse as moving.
self.parentView.horizontalScrollBar().setValue(self.parentView.horizontalScrollBar().value() - (leftEdge - x))
elif x > rightEdge:
self.mouseMoveEventBlocked = True #block the mouseMoveEvent recursion signal. If we scroll it sees the mouse as moving.
self.parentView.horizontalScrollBar().setValue(self.parentView.horizontalScrollBar().value() + (x - rightEdge))
if y < topEdge:
self.mouseMoveEventBlocked = True #block the mouseMoveEvent recursion signal. If we scroll it sees the mouse as moving.
self.parentView.verticalScrollBar().setValue(self.parentView.verticalScrollBar().value() - (topEdge - y))
elif y > bottomEdge:
self.mouseMoveEventBlocked = True #block the mouseMoveEvent recursion signal. If we scroll it sees the mouse as moving.
self.parentView.verticalScrollBar().setValue(self.parentView.verticalScrollBar().value() + (y - bottomEdge))
self.mouseMoveEventBlocked = False
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.
This happens EVERY mouse move. Just idle mouse movement over the window.
if self.duringTrackDragAndDrop:
#X is locked for tracks.
x = self.duringTrackDragAndDrop.staticExportItem["guiPosStart"].x()
y = event.scenePos().y()
self.duringTrackDragAndDrop.setPos(x, y)
elif self.duringBlockDragAndDrop:
#GuiBlockHandles are children of a Track. Their Y position will be offset by the track when we move them around with the mouse.
#Which is the same the distance to the first track is on top. We need to substract that for a good look and feel.
#The actual later dropping of the block is handled with the mouse cursor coordinates, the moving colored block is just eyecandy.
x = event.scenePos().x()
y = event.scenePos().y() - self.duringBlockDragAndDrop.parentGuiTrack.y()
self.duringBlockDragAndDrop.setPos(x, y)
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. #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()])
listOfTrackIds =
#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.
elif tempBlockDragAndDrop: #CC blocks, note blocks and conductor blocks
assert not tempTrackDragAndDrop
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]
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.
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.
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
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 self.parentView.mode() == "notation" and (not (tempBlockDragAndDrop or tempTrackDragAndDrop)) and event.button() == 1: #a positional mouse left click in a note-track, but only it not drag-drop release.
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)
api.toTickindex(trackId, event.scenePos().x() * constantsAndConfigs.ticksToPixelRatio)
elif (not event.button() == 1) and event.scenePos().x() < -25:
#This is also part of createInitialSignatureArea() (Track). We set a margin against misclicks.
def reactToHorizontalScroll(self, value:int):
"""This was created to keep the block-mode centered track name in the center when scrolling.
We forward to all tracks
At point of creation the scroll value was ignored."""
viewCenterAsScenePos = self.parentView.mapToScene(self.parentView.viewport().rect().topLeft())
centerSceneX = viewCenterAsScenePos.x() + 25 #just some padding
for track in self.tracks.values():
#we do not need to call super. this is attached with a connect signal, not a function override.