#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2021 , Nils Hilbricht , Germany ( https : / / www . hilbricht . net )
This file is part of Patroneo ( 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 / > .
"""
import logging ; logger = logging . getLogger ( __name__ ) ; logger . info ( " import " )
from time import time
import engine . api as api #Session is already loaded and created, no duplication.
import template . qtgui . helper as helper
from PyQt5 import QtCore , QtGui , QtWidgets
SIZE_UNIT = 25 #this is in manual sync with timeline.py SIZE_UNIT
SIZE_TOP_OFFSET = 0
_zValuesRelativeToScene = { #Only use for objects added directly to the scene, not children.
" trackStructure " : 3 ,
" barline " : 5 ,
" switch " : 6 ,
" barlineGroupHighlight " : 8 ,
" playhead " : 90 ,
}
class SongEditor ( QtWidgets . QGraphicsScene ) :
def __init__ ( self , parentView ) :
super ( ) . __init__ ( )
self . parentView = parentView
#Set color, otherwise it will be transparent in window managers or wayland that want that.
self . backColor = QtGui . QColor ( 55 , 61 , 69 )
self . setBackgroundBrush ( self . backColor )
self . _exportDictScore = None #cached
#Subitems
self . playhead = Playhead ( parentScene = self )
self . addItem ( self . playhead )
self . playhead . setY ( SIZE_TOP_OFFSET )
self . tracks = { } #TrackID:TrackStructures
self . barlines = [ ] #in order
self . cachedCombinedTrackHeight = 0 #set by callback_tracksChanged
self . trackOrder = [ ] #contains engine-ids, set by callback_numberOfTracksChanged
role = QtGui . QPalette . BrightText
self . brightPen = QtGui . QPen ( self . parentView . parentMainWindow . fPalBlue . color ( role ) )
self . normalPen = QtGui . QPen ( )
api . callbacks . numberOfTracksChanged . append ( self . callback_numberOfTracksChanged )
api . callbacks . timeSignatureChanged . append ( self . callback_timeSignatureChanged )
api . callbacks . numberOfMeasuresChanged . append ( self . callback_setnumberOfMeasures )
api . callbacks . trackStructureChanged . append ( self . callback_trackStructureChanged ) #updates single tracks
api . callbacks . exportCacheChanged . append ( self . cacheExportDict )
api . callbacks . scoreChanged . append ( self . callback_scoreChanged ) #sends information about measuresPerGroup
api . callbacks . trackMetaDataChanged . append ( self . callback_trackMetaDataChanged )
api . callbacks . patternLengthMultiplicatorChanged . append ( self . callback_patternLengthMultiplicatorChanged )
#self.ticksToPixelRatio = None set by callback_timeSignatureChanged
def wheelEvent ( self , event ) :
""" zoom, otherwise ignore event """
if QtWidgets . QApplication . keyboardModifiers ( ) == QtCore . Qt . ControlModifier :
self . parentView . parentMainWindow . zoomUpperHalf ( event . delta ( ) )
event . accept ( )
else :
event . ignore ( )
super ( ) . wheelEvent ( event )
def callback_trackMetaDataChanged ( self , exportDict ) :
""" This is not for the initial track creation, only for later changes """
self . tracks [ exportDict [ " id " ] ] . updateMetaData ( exportDict )
def cacheExportDict ( self , exportDict ) :
""" Does not get called on structure change because callback_trackStructureChanged
also caches the exportDict """
self . tracks [ exportDict [ " id " ] ] . exportDict = exportDict
def callback_trackStructureChanged ( self , exportDict ) :
""" Happens if a switch gets flipped """
track = self . tracks [ exportDict [ " id " ] ]
track . updateSwitches ( exportDict )
def callback_timeSignatureChanged ( self , nr , typ ) :
oneMeasureInTicks = nr * typ
self . ticksToPixelRatio = oneMeasureInTicks / SIZE_UNIT
def callback_numberOfTracksChanged ( self , exportDictList ) :
""" Used for new tracks, delete track and move track """
toDelete = set ( self . tracks . keys ( ) )
self . trackOrder = [ ]
for index , exportDict in enumerate ( exportDictList ) :
if exportDict [ " id " ] in self . tracks :
toDelete . remove ( exportDict [ " id " ] ) #keep this track and don't delete later.
else : #new track
self . tracks [ exportDict [ " id " ] ] = TrackStructure ( parentScene = self )
self . addItem ( self . tracks [ exportDict [ " id " ] ] )
self . tracks [ exportDict [ " id " ] ] . setZValue ( _zValuesRelativeToScene [ " trackStructure " ] )
self . trackOrder . append ( self . tracks [ exportDict [ " id " ] ] )
self . tracks [ exportDict [ " id " ] ] . setY ( index * SIZE_UNIT + SIZE_TOP_OFFSET )
self . tracks [ exportDict [ " id " ] ] . updateSwitches ( exportDict )
self . tracks [ exportDict [ " id " ] ] . updateStaffLines ( exportDict [ " numberOfMeasures " ] )
#We had these tracks in the GUI but they are gone in the export. This is track delete.
for trackId in toDelete :
trackStructure = self . tracks [ trackId ]
for position , switch in trackStructure . switches . items ( ) :
self . removeItem ( switch ) #switches are direct children of the scene. delete them here.
#we don't need to delete from trackOrder here because that is cleared each time we call this function
del self . tracks [ trackId ]
self . removeItem ( trackStructure ) #remove from scene
del trackStructure
assert all ( track . exportDict [ " sequencerInterface " ] [ " index " ] == self . trackOrder . index ( track ) for track in self . tracks . values ( ) )
self . cachedCombinedTrackHeight = len ( self . tracks ) * SIZE_UNIT
self . setSceneRect ( 0 , 0 , exportDict [ " numberOfMeasures " ] * SIZE_UNIT , self . cachedCombinedTrackHeight + 3 * SIZE_TOP_OFFSET ) #no empty space on top without a scene rect. Also a bit of leniance.
self . playhead . setLine ( 0 , 0 , 0 , self . cachedCombinedTrackHeight ) #(x1, y1, x2, y2)
self . adjustBarlineHeightForNewTrackCount ( )
def adjustBarlineHeightForNewTrackCount ( self ) :
""" Fetches the current context itself and modifies all existing barlines.
"""
for barline in self . barlines :
barline . setLine ( 0 , 0 , 0 , self . cachedCombinedTrackHeight )
def callback_setnumberOfMeasures ( self , exportDictScore ) :
self . _exportDictScore = exportDictScore
requestAmountOfMeasures = exportDictScore [ " numberOfMeasures " ]
requestAmountOfMeasures + = 1 #the final closing barline
maximumAmountIncludingHidden = len ( self . barlines )
if requestAmountOfMeasures == maximumAmountIncludingHidden :
for l in self . barlines : l . show ( )
elif requestAmountOfMeasures > maximumAmountIncludingHidden : #we need more than we have. Maybe new ones.
for l in self . barlines : l . show ( )
for i in range ( maximumAmountIncludingHidden , requestAmountOfMeasures ) :
barline = QtWidgets . QGraphicsLineItem ( 0 , 0 , 0 , 1 ) #correct length will be set below, but we need something other than 0 here
self . addItem ( barline )
barline . setAcceptedMouseButtons ( QtCore . Qt . NoButton ) #barlines will intercept clicks on the track otherwise. We keep the horizontal stafflines blocking to prevent accidents though.
barline . setPos ( i * SIZE_UNIT , SIZE_TOP_OFFSET )
barline . setEnabled ( False )
barline . setZValue ( _zValuesRelativeToScene [ " barline " ] )
self . barlines . append ( barline )
else : #user reduced the number of barlines. We only hide, never delete.
for l in self . barlines [ requestAmountOfMeasures : ] :
l . hide ( )
#Guaranteed visible.
for l in self . barlines [ : requestAmountOfMeasures ] :
l . show ( )
self . callback_scoreChanged ( exportDictScore ) #colors from the start
self . adjustBarlineHeightForNewTrackCount ( ) #otherwise only the new ones have the correct height.
self . setSceneRect ( 0 , 0 , requestAmountOfMeasures * SIZE_UNIT , self . cachedCombinedTrackHeight + 3 * SIZE_TOP_OFFSET ) #no empty space on top without a scene rect
for track in self . tracks . values ( ) :
factor = track . exportDict [ " patternLengthMultiplicator " ]
track . updateSwitchVisibility ( requestAmountOfMeasures = ( requestAmountOfMeasures - 1 ) / / factor )
track . updateStaffLines ( requestAmountOfMeasures - 1 )
def callback_scoreChanged ( self , exportDictScore ) :
self . _exportDictScore = exportDictScore
self . measuresPerGroupCache = exportDictScore [ " measuresPerGroup " ]
for i , barline in enumerate ( self . barlines ) :
if i > 0 and ( i + 1 ) % exportDictScore [ " measuresPerGroup " ] == 1 :
barline . setPen ( self . brightPen )
barline . setZValue ( _zValuesRelativeToScene [ " barlineGroupHighlight " ] )
else :
barline . setPen ( self . normalPen )
barline . setZValue ( _zValuesRelativeToScene [ " barline " ] )
def callback_patternLengthMultiplicatorChanged ( self , exportDict ) :
""" This is only for a single track. We relay it """
track = self . tracks [ exportDict [ " id " ] ]
track . updatePatternLengthMultiplicator ( exportDict )
class TrackStructure ( QtWidgets . QGraphicsRectItem ) :
""" From left to right. Holds two lines to show the " staffline " and a number of switches,
colored rectangles to indicate where a pattern is activated on the timeline """
def __init__ ( self , parentScene ) :
super ( ) . __init__ ( 0 , 0 , 1 , SIZE_UNIT )
self . parentScene = parentScene
self . setAcceptHoverEvents ( True ) #for the preview highlight switch
self . exportDict = None #self.update gets called immediately after creation.
self . switches = { } # position:switchInstance
self . currentColor = None #set in updateMetaData
self . labelColor = None #set in updateMetaData for redable labels on our color. for example transpose number
#The track holds the horizontal lines. The number of barlines is calculated in the parentScene for all tracks at once.
self . topLine = QtWidgets . QGraphicsLineItem ( 0 , 0 , 1 , 0 ) #empty line is not possible. We need at least something to let self.update work with
self . topLine . setParentItem ( self )
self . topLine . setPos ( 0 , 0 )
self . bottomLine = QtWidgets . QGraphicsLineItem ( 0 , 0 , 1 , 0 ) #empty line is not possible. We need at least something to let self.update work with
self . bottomLine . setParentItem ( self )
self . bottomLine . setPos ( 0 , SIZE_UNIT )
self . topLine . setEnabled ( False )
self . bottomLine . setEnabled ( False )
#Interactive Marker to select several switches in a row
self . _mousePressOn = None #to remember the position of a mouse click
#self._markerLine = QtWidgets.QGraphicsLineItem(0,0,10,0) #only updated, never replaced
self . _markerLine = QtWidgets . QGraphicsRectItem ( 0 , 0 , 10 , SIZE_UNIT ) #only updated, never replaced
#self._markerLine.setParentItem(self) #incompatible with zValues. We need this relative to the scene
self . parentScene . addItem ( self . _markerLine )
#self._markerLine.setY(self.pos().y()) #won't work yet. We are not in the scene ourselves. We set it in mousePressEvent, before show()
self . _markerLine . setZValue ( _zValuesRelativeToScene [ " switch " ] )
self . _markerLine . hide ( )
#Semitransparent hover-switch to show which one would be activated/deactivated
#Color and position is set in the callbacks and mouse handling
#It is below the actual switch so it will not show when there is already a switch, which is ok
self . _highlightSwitch = QtWidgets . QGraphicsRectItem ( 0 , 0 , SIZE_UNIT , SIZE_UNIT )
self . _highlightSwitch . setParentItem ( self )
self . _highlightSwitch . setOpacity ( 0.2 )
self . _highlightSwitch . hide ( )
def _setColors ( self , exportDict ) :
""" Called from various callbacks like updateSwitches and updateMetadata """
self . exportDict = exportDict
c = QtGui . QColor ( exportDict [ " color " ] )
self . currentColor = c
self . _highlightSwitch . setBrush ( c ) #this is with low opacity.
if c . lightness ( ) > 127 : #between 0 (for black) and 255 (for white)
labelColor = QtGui . QColor ( " black " )
else :
labelColor = QtGui . QColor ( " white " )
self . labelColor = labelColor #save for new switches
def updateSwitches ( self , exportDict ) :
self . exportDict = exportDict
self . _setColors ( exportDict )
#Create new switches
for position in exportDict [ " structure " ] :
if not position in self . switches :
self . switches [ position ] = self . _createSwitch ( position )
factor = exportDict [ " patternLengthMultiplicator " ]
effectiveNumberOfMeasures = exportDict [ " numberOfMeasures " ] / / factor # //integer division
self . updateSwitchVisibility ( effectiveNumberOfMeasures )
def updateMetaData ( self , exportDict ) :
""" Color and Transposition status.
Does not get called on track structure change . """
self . exportDict = exportDict
self . _setColors ( exportDict )
for switch in self . switches . values ( ) :
switch . setBrush ( self . currentColor )
switch . setScaleTransposeColor ( self . labelColor )
switch . setHalftoneTransposeColor ( self . labelColor )
def updatePatternLengthMultiplicator ( self , exportDict ) :
""" Comes via its own callback, also named callback_patternLengthMultiplicatorChanged.
The spinBox to set this is in TrackLabel """
self . updateSwitches ( exportDict ) # contains exportDict caching.
effectiveNumberOfMeasures = exportDict [ " numberOfMeasures " ] / / exportDict [ " patternLengthMultiplicator " ] # //integer division
#self.updateStaffLines(effectiveNumberOfMeasures) #we do not need to adjust the overall track length. That stays the same, no matter the factor.
def updateStaffLines ( self , requestAmountOfMeasures ) :
""" The two horizontal lines that mark our track.
We do NOT need to handle patternLengthMultiplicator since the overall track length
stays the same . Just the measure divisions are different .
"""
l = self . topLine . line ( )
l . setLength ( requestAmountOfMeasures * SIZE_UNIT )
self . topLine . setLine ( l )
l = self . bottomLine . line ( )
l . setLength ( requestAmountOfMeasures * SIZE_UNIT )
self . bottomLine . setLine ( l )
#Update self, which is the track background
self . setRect ( 0 , 0 , requestAmountOfMeasures * SIZE_UNIT , SIZE_UNIT )
def _createSwitch ( self , position ) :
""" Called only by self.updateSwitches
Qt can ' t put the same item into the scene twice. We need to create a new one each time " " "
switch = Switch ( parentTrackStructure = self , position = position )
assert self . currentColor
switch . setBrush ( self . currentColor )
#switch.setParentItem(self) #prevents zValue because switches are children of trackStructure. add to scene directly instead:
#For now we assume no stretch factor. One measure is one base measure.
#We set that in self.updateSwitchVisibility
self . scene ( ) . addItem ( switch )
switch . setPos ( position * SIZE_UNIT , self . y ( ) )
switch . setZValue ( _zValuesRelativeToScene [ " switch " ] )
return switch
def updateSwitchVisibility ( self , requestAmountOfMeasures ) :
""" Switch pattern-visibility on and off.
This never creates or deletes switches
We assume self . exportDict is up to date
because we get called by self . updateSwitches , which saves the exportDict . """
structure = self . exportDict [ " structure " ]
whichPatternsAreScaleTransposed = self . exportDict [ " whichPatternsAreScaleTransposed " ]
whichPatternsAreHalftoneTransposed = self . exportDict [ " whichPatternsAreHalftoneTransposed " ]
factor = self . exportDict [ " patternLengthMultiplicator " ]
#requestAmountOfMeasures = requestAmountOfMeasures // factor #already is.
#Adjust highlight mouse hover to new stretch factor
r = self . _highlightSwitch . rect ( )
r . setRight ( SIZE_UNIT * factor )
self . _highlightSwitch . setRect ( r )
for position , switch in self . switches . items ( ) :
#Deal with measures that stretch multiple base measures
switch . stretch ( factor )
switch . setPos ( position * SIZE_UNIT * factor , self . y ( ) )
#both position and requestAmountOfMeasures below are adjusted with scale factors
if not position in structure :
switch . hide ( ) #Not delete because this may be just a temporary reduction of measures
switch . scaleTransposeOff ( )
elif position > = requestAmountOfMeasures : #switch is out of bounds. For factor 1 this is the same as not in the score-area
switch . hide ( )
switch . scaleTransposeOff ( )
else :
switch . show ( )
if position in whichPatternsAreScaleTransposed :
switch . setScaleTranspose ( - 1 * whichPatternsAreScaleTransposed [ position ] ) #we flip the polarity from "makes sense" to row based "lower is higher" here. The opposite, sending, flip is done in switch hover leave event
else :
switch . scaleTransposeOff ( )
if position in whichPatternsAreHalftoneTransposed :
switch . setHalftoneTranspose ( whichPatternsAreHalftoneTransposed [ position ] ) #half tone transposition is not flipped
else :
switch . halftoneTransposeOff ( )
def contextMenuEvent ( self , event ) :
if self . _mousePressOn : #Right click can happen while the left button is still pressed down, which we don't want.
return
menu = QtWidgets . QMenu ( )
position = self . scenePos2switchPosition ( event . scenePos ( ) . x ( ) ) #measure number 0 based
position = position * self . exportDict [ " patternLengthMultiplicator " ] #We need the position in the global system, without the factor
measuresPerGroup = self . parentScene . measuresPerGroupCache
offset = position % measuresPerGroup
startMeasureForGroup = position - offset
endMeasureExclusive = startMeasureForGroup + measuresPerGroup
listOfLabelsAndFunctions = [
( QtCore . QCoreApplication . translate ( " SongStructure " , " Insert empty group before this one " ) , lambda : api . insertSilence ( howMany = measuresPerGroup , beforeMeasureNumber = startMeasureForGroup ) ) ,
( QtCore . QCoreApplication . translate ( " SongStructure " , " Exchange group with right neigbour " ) , lambda : api . exchangeSwitchGroupWithGroupToTheRight ( startMeasureForGroup , endMeasureExclusive ) ) ,
( QtCore . QCoreApplication . translate ( " SongStructure " , " Delete whole group " ) , lambda : api . deleteSwitches ( howMany = measuresPerGroup , fromMeasureNumber = startMeasureForGroup ) ) ,
( QtCore . QCoreApplication . translate ( " SongStructure " , " Duplicate whole group including measures " ) , lambda : api . duplicateSwitchGroup ( startMeasureForGroup , endMeasureExclusive ) ) ,
( QtCore . QCoreApplication . translate ( " SongStructure " , " Clear all group transpositions " ) , lambda : api . clearSwitchGroupTranspositions ( startMeasureForGroup , endMeasureExclusive ) ) ,
]
for text , function in listOfLabelsAndFunctions :
a = QtWidgets . QAction ( text , menu )
menu . addAction ( a )
a . triggered . connect ( function )
pos = QtGui . QCursor . pos ( )
pos . setY ( pos . y ( ) + 5 )
self . parentScene . parentView . parentMainWindow . setFocus ( )
menu . exec_ ( pos )
def scenePos2switchPosition ( self , x ) :
""" Map scene coordinates to counted switch engine position """
factor = self . exportDict [ " patternLengthMultiplicator " ]
return int ( x / SIZE_UNIT / factor )
def mousePressEvent ( self , event ) :
#First we need to find the mouse clicks position. self.switches only holds pos that were at least activated once.
#The track is only the area where the rectangles and lines meet. it is impossible to click below or right of the tracks.
#we always get a valid position this way.
if event . button ( ) == QtCore . Qt . MiddleButton and not self . _mousePressOn :
self . parentScene . parentView . parentMainWindow . patternGrid . createShadow ( self . exportDict )
else :
self . parentScene . parentView . parentMainWindow . chooseCurrentTrack ( self . exportDict )
if event . button ( ) == QtCore . Qt . LeftButton : #Create a switch or continue to hold down mouse button and drag to draw -> mouseMoveEvent
assert not self . _mousePressOn
position = self . scenePos2switchPosition ( event . scenePos ( ) . x ( ) ) #measure number 0 based
self . _markerLine . setX ( position * SIZE_UNIT * self . exportDict [ " patternLengthMultiplicator " ] )
self . _markerLine . setY ( self . pos ( ) . y ( ) ) #we can't do that in init because self is not in the scene by then. markerLine is a child directly to the scene, no magic reparenting.
newBool = not position in self . switches or not self . switches [ position ] . isVisible ( )
if newBool :
self . _markerLine . setBrush ( self . currentColor )
else :
self . _markerLine . setBrush ( self . parentScene . parentView . parentMainWindow . fPalBlue . color ( QtGui . QPalette . AlternateBase ) ) #we are always the active track so this is our color
self . _mousePressOn = ( time ( ) , self , position , newBool ) #Reset to None in mouseReleaseEvent
result = api . setSwitch ( self . exportDict [ " id " ] , position , newBool ) #returns True if a switch happend
#elif event.button() == QtCore.Qt.RightButton and not self._mousePressOn:
#no, this is done with contextMenuEvent directly so it also reacts to the context menu keyboard key.
def mouseMoveEvent ( self , event ) :
""" Draw new switches by holding and dragging the mouse
This is only the visual aspect . Actual calcuation and insert is in mouseReleaseEvent
The initial _markerLine is setup in mousePressEvent , where the first switch is already set as well
In Patroneo this is only triggered when left mouse button is down .
We don ' t set the Qt flag to always react " " "
position = self . scenePos2switchPosition ( event . scenePos ( ) . x ( ) ) #measure number 0 based
factor = self . exportDict [ " patternLengthMultiplicator " ]
if self . _mousePressOn and position != self . _mousePressOn [ 2 ] :
#self._markerLine.setLine(0,0, (position - self._mousePressOn[2])*SIZE_UNIT + SIZE_UNIT/2, 0)
rect = self . _markerLine . rect ( )
if position < 0 :
position = 0
elif position + 1 > self . exportDict [ " numberOfMeasures " ] : #position is already a switch position
position = self . exportDict [ " numberOfMeasures " ] - 1
if position < self . _mousePressOn [ 2 ] :
left = ( position - self . _mousePressOn [ 2 ] ) * SIZE_UNIT
rect . setLeft ( left * factor )
rect . setRight ( SIZE_UNIT )
else :
right = ( position - self . _mousePressOn [ 2 ] ) * SIZE_UNIT + SIZE_UNIT
rect . setRight ( right * factor )
rect . setLeft ( 0 )
self . _markerLine . setRect ( rect )
self . _markerLine . show ( )
else :
self . _markerLine . hide ( )
def hoverEnterEvent ( self , event ) :
self . _highlightSwitch . show ( )
#This seemed to be a good idea but horrible UX. If you move the mouse down to edit a pattern you end up choosing the last track
#self.scene().parentView.parentMainWindow.chooseCurrentTrack(self.exportDict)
def hoverLeaveEvent ( self , event ) :
self . _highlightSwitch . hide ( )
def hoverMoveEvent ( self , event ) :
""" Snap the highlight switch to grid and stretch factor """
#x = round((event.scenePos().x() / SIZE_UNIT)-1) * SIZE_UNIT
switchPos = self . scenePos2switchPosition ( event . scenePos ( ) . x ( ) )
factor = self . exportDict [ " patternLengthMultiplicator " ]
x = switchPos * SIZE_UNIT * factor
self . _highlightSwitch . setX ( x )
def mouseReleaseEvent ( self , event ) :
if event . button ( ) == QtCore . Qt . LeftButton :
self . _markerLine . hide ( )
position = self . scenePos2switchPosition ( event . scenePos ( ) . x ( ) )
if position < 0 :
position = 0
elif position + 1 > self . exportDict [ " numberOfMeasures " ] : #position is already a switch position
position = self . exportDict [ " numberOfMeasures " ] - 1
startTime , startTrack , startPosition , setTo = self . _mousePressOn
self . _mousePressOn = None
if not startPosition == position and time ( ) - startTime > 0.4 : #optimisation to spare the engine from redundant work. Also prevent hectic drag-clicking
#setTo is a bool that tells us if all the switches in our range should go on (True) or off (False). The first switch, startPosition, is already set in mousePressEvent for a better user experience.
low , high = sorted ( ( startPosition , position ) ) #both included
setOfPositions = set ( range ( low , high + 1 ) ) #range does not include the last one, we want it in. it MUST be a set.
api . setSwitches ( self . exportDict [ " id " ] , setOfPositions , setTo )
def mark ( self , boolean ) :
""" Mark the whole Track as active or not """
if boolean :
role = QtGui . QPalette . AlternateBase
else :
role = QtGui . QPalette . Base
c = self . parentScene . parentView . parentMainWindow . fPalBlue . color ( role )
self . setBrush ( c )
class Switch ( QtWidgets . QGraphicsRectItem ) :
""" Switches live for the duration of the track. Once created they only ever get hidden/shown,
never deleted .
Not every " empty square " has a switch already . Only switches that were activated at least once .
"""
def __init__ ( self , parentTrackStructure , position ) :
self . parentTrackStructure = parentTrackStructure
self . position = position
super ( ) . __init__ ( 0 , 0 , SIZE_UNIT , SIZE_UNIT )
#self.rect().setWidth(SIZE_UNIT)
self . setAcceptHoverEvents ( True )
self . scaleTransposeGlyph = QtWidgets . QGraphicsSimpleTextItem ( " " )
self . scaleTransposeGlyph . setParentItem ( self )
self . scaleTransposeGlyph . setScale ( 0.80 )
self . scaleTransposeGlyph . setPos ( 2 , 1 )
self . scaleTransposeGlyph . setBrush ( self . parentTrackStructure . labelColor )
self . scaleTransposeGlyph . hide ( )
self . scaleTranspose = 0
self . halftoneTransposeGlyph = QtWidgets . QGraphicsSimpleTextItem ( " " )
self . halftoneTransposeGlyph . setParentItem ( self )
self . halftoneTransposeGlyph . setScale ( 0.80 )
self . halftoneTransposeGlyph . setPos ( 1 , 13 )
self . halftoneTransposeGlyph . setBrush ( self . parentTrackStructure . labelColor )
self . halftoneTransposeGlyph . hide ( )
self . halftoneTranspose = 0
def stretch ( self , factor ) :
""" factor assumes relative to SIZE_UNIT """
r = self . rect ( )
r . setRight ( SIZE_UNIT * factor )
self . setRect ( r )
def setScaleTranspose ( self , value ) :
"""
Called by track callbacks and also for the temporary buffer display
while internally both the engine and us , the GUI , use steps and transposition through
" negative is higher pitch " we present it reversed for the user .
Greater number is higher pitch
It is guaranteed that only active switches can have a transposition .
Also transposition = 0 is not included .
"""
self . scaleTranspose = value
self . _setScaleTransposeLabel ( value )
def _setScaleTransposeLabel ( self , value ) :
text = ( " + " if value > 0 else " " ) + str ( value ) + " s "
self . scaleTransposeGlyph . setText ( text )
self . scaleTransposeGlyph . show ( )
def setScaleTransposeColor ( self , c ) :
self . scaleTransposeGlyph . setBrush ( c )
def scaleTransposeOff ( self ) :
self . scaleTransposeGlyph . setText ( " " )
#self.scaleTransposeGlyph.hide()
self . scaleTranspose = 0
self . _bufferScaleTranspose = 0
def setHalftoneTranspose ( self , value ) :
self . halftoneTranspose = value
self . _setHalftoneTransposeLabel ( value )
def _setHalftoneTransposeLabel ( self , value ) :
text = ( " + " if value > 0 else " " ) + str ( value ) + " h "
self . halftoneTransposeGlyph . setText ( text )
self . halftoneTransposeGlyph . show ( )
def setHalftoneTransposeColor ( self , c ) :
self . halftoneTransposeGlyph . setBrush ( c )
def halftoneTransposeOff ( self ) :
self . halftoneTransposeGlyph . setText ( " " )
#self.halftoneTransposeGlyph.hide()
self . halftoneTranspose = 0
self . _bufferhalftoneTranspose = 0
def mousePressEvent ( self , event ) :
""" A mouse events on the track activate a switch. Then we receive the event to turn it
off again . """
event . ignore ( )
def hoverEnterEvent ( self , event ) :
self . _bufferScaleTranspose = self . scaleTranspose
self . _bufferHalftoneTranspose = self . halftoneTranspose
def hoverLeaveEvent ( self , event ) :
""" only triggered when active/shown """
event . accept ( )
#Scale Transpose. Independent of Halftone Transpose
if not self . _bufferScaleTranspose == self . scaleTranspose :
api . setSwitchScaleTranspose ( self . parentTrackStructure . exportDict [ " id " ] , self . position , - 1 * self . _bufferScaleTranspose ) #we flip the polarity here. The receiving flip is done in the callback.
#new transpose/buffer gets set via callback
if self . _bufferScaleTranspose == 0 :
self . scaleTransposeOff ( )
#Halftone Transpose. Independent of Scale Transpose
if not self . _bufferHalftoneTranspose == self . halftoneTranspose :
api . setSwitchHalftoneTranspose ( self . parentTrackStructure . exportDict [ " id " ] , self . position , self . _bufferHalftoneTranspose ) #half tone transposition is not flipped
#new transpose/buffer gets set via callback
if self . _bufferHalftoneTranspose == 0 :
self . halftoneTransposeOff ( )
def wheelEvent ( self , event ) :
""" Does not get triggered when switch is off.
This buffers until hoverLeaveEvent and then the new value is sent in self . hoverLeaveEvent """
event . accept ( )
if QtWidgets . QApplication . keyboardModifiers ( ) == QtCore . Qt . ShiftModifier : #half tone transposition
if event . delta ( ) > 0 :
self . _bufferHalftoneTranspose = min ( + 24 , self . _bufferHalftoneTranspose + 1 )
else :
self . _bufferHalftoneTranspose = max ( - 24 , self . _bufferHalftoneTranspose - 1 )
self . _setHalftoneTransposeLabel ( self . _bufferHalftoneTranspose )
else : #scale transposition
if event . delta ( ) > 0 :
self . _bufferScaleTranspose = min ( + 7 , self . _bufferScaleTranspose + 1 )
else :
self . _bufferScaleTranspose = max ( - 7 , self . _bufferScaleTranspose - 1 )
self . _setScaleTransposeLabel ( self . _bufferScaleTranspose )
class TrackLabelEditor ( QtWidgets . QGraphicsScene ) :
""" Only the track labels """
def __init__ ( self , parentView ) :
super ( ) . __init__ ( )
self . parentView = parentView
self . tracks = { } #TrackID:TrackLabel
self . _cachedExportDictsInOrder = [ ]
self . _exportDictScore = None #cache
#Set color, otherwise it will be transparent in window managers or wayland that want that.
self . backColor = QtGui . QColor ( 55 , 61 , 69 )
self . backColor = QtGui . QColor ( 48 , 53 , 60 )
self . setBackgroundBrush ( self . backColor )
api . callbacks . numberOfTracksChanged . append ( self . callback_numberOfTracksChanged )
api . callbacks . trackMetaDataChanged . append ( self . callback_trackMetaDataChanged )
api . callbacks . numberOfMeasuresChanged . append ( self . callback_setnumberOfMeasures )
api . callbacks . exportCacheChanged . append ( self . cacheExportDict )
api . callbacks . patternLengthMultiplicatorChanged . append ( self . callback_patternLengthMultiplicatorChanged )
self . cachedCombinedTrackHeight = 0 #set by callback_tracksChanged
def cacheExportDict ( self , exportDict ) :
self . tracks [ exportDict [ " id " ] ] . exportDict = exportDict
def callback_trackMetaDataChanged ( self , exportDict ) :
""" This is not for the initial track creation, only for later changes """
self . tracks [ exportDict [ " id " ] ] . update ( exportDict )
def callback_setnumberOfMeasures ( self , exportDictScore ) :
self . _exportDictScore = exportDictScore
def callback_numberOfTracksChanged ( self , exportDictList ) :
toDelete = set ( self . tracks . keys ( ) )
self . _cachedExportDictsInOrder = exportDictList
width = self . parentView . geometry ( ) . width ( )
for index , exportDict in enumerate ( exportDictList ) :
if exportDict [ " id " ] in self . tracks :
toDelete . remove ( exportDict [ " id " ] )
else : #new track
self . tracks [ exportDict [ " id " ] ] = TrackLabel ( parentScene = self , width = width , height = SIZE_UNIT )
self . addItem ( self . tracks [ exportDict [ " id " ] ] )
self . tracks [ exportDict [ " id " ] ] . setY ( index * SIZE_UNIT + SIZE_TOP_OFFSET )
self . tracks [ exportDict [ " id " ] ] . update ( exportDict )
#We had this tracks in the GUI but they are gone in the export. This is track delete.
for trackId in toDelete :
trackLabel = self . tracks [ trackId ]
del self . tracks [ trackId ]
self . removeItem ( trackLabel ) #remove from scene
del trackLabel
if toDelete and self . parentView . parentMainWindow . currentTrackId in toDelete : #toDelete still exist if tracks were deleted above
anyExistingTrack = next ( iter ( self . tracks . values ( ) ) )
self . parentView . parentMainWindow . chooseCurrentTrack ( anyExistingTrack . exportDict )
self . cachedCombinedTrackHeight = len ( self . tracks ) * SIZE_UNIT
self . setSceneRect ( 0 , 0 , width - 1 , self . cachedCombinedTrackHeight + 3 * SIZE_TOP_OFFSET )
def callback_patternLengthMultiplicatorChanged ( self , exportDict ) :
self . tracks [ exportDict [ " id " ] ] . update ( exportDict ) #general update function that also covers our value
def contextMenuEvent ( self , event ) :
"""
We can ' t delete this properly object from within. The engine callback will react faster
than we need to finish this function . That means qt and python will try to access
objects that are non - existent """
menu = QtWidgets . QMenu ( )
item = self . itemAt ( event . scenePos ( ) . x ( ) , event . scenePos ( ) . y ( ) , self . parentView . transform ( ) )
if not type ( item ) is QtWidgets . QGraphicsProxyWidget :
return None
exportDict = item . parentItem ( ) . exportDict . copy ( )
del item #yes, manual memory management in Python. We need to get rid of anything we want to delete later or Qt will crash because we have a python wrapper without a qt object
listOfLabelsAndFunctions = [
( exportDict [ " sequencerInterface " ] [ " name " ] , None ) ,
( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " Invert Measures " ) , lambda : api . trackInvertSwitches ( exportDict [ " id " ] ) ) ,
( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " All Measures On " ) , lambda : api . trackOnAllSwitches ( exportDict [ " id " ] ) ) ,
( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " All Measures Off " ) , lambda : api . trackOffAllSwitches ( exportDict [ " id " ] ) ) ,
( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " Clone this Track " ) , lambda : api . createSiblingTrack ( exportDict [ " id " ] ) ) ,
( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " Delete Track " ) , lambda : api . deleteTrack ( exportDict [ " id " ] ) ) ,
]
for text , function in listOfLabelsAndFunctions :
if function is None :
l = QtWidgets . QLabel ( text )
l . setAlignment ( QtCore . Qt . AlignCenter )
a = QtWidgets . QWidgetAction ( menu )
a . setDefaultWidget ( l )
menu . addAction ( a )
else :
a = QtWidgets . QAction ( text , menu )
menu . addAction ( a )
a . triggered . connect ( function )
#Add a submenu for merge/cop
mergeMenu = menu . addMenu ( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " Merge/Copy Measure-Structure from " ) )
def createCopyMergeLambda ( srcId ) :
return lambda : api . trackMergeCopyFrom ( srcId , exportDict [ " id " ] )
for sourceDict in self . _cachedExportDictsInOrder :
a = QtWidgets . QAction ( sourceDict [ " sequencerInterface " ] [ " name " ] , mergeMenu )
mergeMenu . addAction ( a )
mergeCommand = createCopyMergeLambda ( sourceDict [ " id " ] )
if sourceDict [ " id " ] == exportDict [ " id " ] :
a . setEnabled ( False )
a . triggered . connect ( mergeCommand )
#Add a submenu for pattern merge/copy
copyMenu = menu . addMenu ( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " Replace Pattern with " ) )
def replacePatternWithLambda ( srcId ) :
return lambda : api . trackPatternReplaceFrom ( srcId , exportDict [ " id " ] )
for sourceDict in self . _cachedExportDictsInOrder :
a = QtWidgets . QAction ( sourceDict [ " sequencerInterface " ] [ " name " ] , copyMenu )
copyMenu . addAction ( a )
mergeCommand = replacePatternWithLambda ( sourceDict [ " id " ] )
if sourceDict [ " id " ] == exportDict [ " id " ] :
a . setEnabled ( False )
a . triggered . connect ( mergeCommand )
#Add a submenu to set the midi channel of this track. Highlight the current one
midiChannelMenu = menu . addMenu ( QtCore . QCoreApplication . translate ( " TrackLabelContext " , " Send on MIDI Channel " ) )
for mch in range ( 1 , 17 ) :
mchAction = QtWidgets . QAction ( str ( mch ) , midiChannelMenu )
midiChannelMenu . addAction ( mchAction )
midiChannelCommand = lambda discard , chArg = mch : api . changeTrackMidiChannel ( exportDict [ " id " ] , chArg ) #discard parameter given by QAction
if exportDict [ " midiChannel " ] == mch :
mchAction . setEnabled ( False )
mchAction . triggered . connect ( midiChannelCommand )
pos = QtGui . QCursor . pos ( )
pos . setY ( pos . y ( ) + 5 )
self . parentView . parentMainWindow . setFocus ( )
menu . exec_ ( pos )
class TrackLabel ( QtWidgets . QGraphicsRectItem ) :
def __init__ ( self , parentScene , width , height ) :
super ( ) . __init__ ( 0 , 0 , width , height )
self . parentScene = parentScene
self . setPen ( QtGui . QPen ( QtCore . Qt . NoPen ) )
self . setFlag ( self . ItemIgnoresTransformations ) #zoom will repostion but not make the font bigger.
#Child widgets. SIZE_UNIT is used as positioning block. Multiply yourself :)
self . positioningHandle = TrackLabel . PositioningHandle ( parentTrackLabel = self )
self . positioningHandle . setParentItem ( self )
self . positioningHandle . setPos ( 0 , 0 )
self . positioningHandle . setToolTip ( QtCore . QCoreApplication . translate ( " TrackLabel " , " grab and move to reorder tracks " ) )
self . lengthMultiplicatorSpinBox = TrackLabel . lengthMultiplicatorSpinBox ( parentTrackLabel = self )
self . lengthMultiplicatorSpinBox . setParentItem ( self )
self . lengthMultiplicatorSpinBox . setPos ( SIZE_UNIT , 0 )
self . colorButton = TrackLabel . ColorPicker ( parentTrackLabel = self )
self . colorButton . setParentItem ( self )
self . colorButton . setPos ( 4 * SIZE_UNIT , 3 )
self . colorButton . setToolTip ( QtCore . QCoreApplication . translate ( " TrackLabel " , " change track color " ) )
self . lineEdit = TrackLabel . NameLineEdit ( parentTrackLabel = self )
self . label = QtWidgets . QGraphicsProxyWidget ( )
self . label . setWidget ( self . lineEdit )
self . label . setParentItem ( self )
self . label . setPos ( 5 * SIZE_UNIT + 3 , 0 )
class lengthMultiplicatorSpinBox ( QtWidgets . QGraphicsProxyWidget ) :
def __init__ ( self , parentTrackLabel ) :
super ( ) . __init__ ( )
self . parentTrackLabel = parentTrackLabel
self . spinBox = QtWidgets . QSpinBox ( )
self . spinBox . setSuffix ( " x " )
#self.spinBox.setFrame(True)
self . spinBox . setMinimum ( 1 )
self . setWidget ( self . spinBox )
self . spinBox . valueChanged . connect ( self . spinBoxValueChanged ) #Callback for setting is in ParentTrackLabel.update
def spinBoxValueChanged ( self ) :
self . parentTrackLabel . parentScene . parentView . parentMainWindow . chooseCurrentTrack ( self . parentTrackLabel . exportDict )
api . setTrackPatternLengthMultiplicator ( self . parentTrackLabel . exportDict [ " id " ] , self . spinBox . value ( ) )
class ColorPicker ( QtWidgets . QGraphicsRectItem ) :
def __init__ ( self , parentTrackLabel ) :
super ( ) . __init__ ( 0 , 0 , SIZE_UNIT * 0.75 , SIZE_UNIT * 0.75 )
self . parentTrackLabel = parentTrackLabel
self . setBrush ( QtGui . QColor ( " cyan " ) )
def mousePressEvent ( self , event ) :
if event . button ( ) == QtCore . Qt . LeftButton :
self . parentTrackLabel . parentScene . parentView . parentMainWindow . chooseCurrentTrack ( self . parentTrackLabel . exportDict )
event . accept ( )
colorDialog = QtWidgets . QColorDialog ( )
color = colorDialog . getColor ( self . brush ( ) . color ( ) ) #blocks
if color . isValid ( ) : #and not abort
#self.setBrush(color) #done via callback.
api . changeTrackColor ( self . parentTrackLabel . exportDict [ " id " ] , color . name ( ) )
#else:
# colorDialog.setStandardColor(self.brush().color())
else :
event . ignore ( )
#super().mousePressEvent(event)
class PositioningHandle ( QtWidgets . QGraphicsEllipseItem ) :
def __init__ ( self , parentTrackLabel ) :
super ( ) . __init__ ( 0 , 0 , SIZE_UNIT - 2 , SIZE_UNIT - 2 )
self . parentTrackLabel = parentTrackLabel
self . setPen ( QtGui . QPen ( QtCore . Qt . NoPen ) )
role = QtGui . QPalette . ToolTipBase
c = self . parentTrackLabel . parentScene . parentView . parentMainWindow . fPalBlue . color ( role )
self . setBrush ( c )
self . setOpacity ( 0.08 ) #this is meant as a slight overlay/highlight of both the current track and the other tracks
self . arrowLabel = QtWidgets . QGraphicsSimpleTextItem ( " ↕ " )
self . arrowLabel . setFlag ( QtWidgets . QGraphicsItem . ItemIgnoresParentOpacity )
self . arrowLabel . setParentItem ( self )
self . arrowLabel . setScale ( 1.6 )
self . arrowLabel . setPos ( 2 , 1 )
role = QtGui . QPalette . Text
self . arrowLabel . setBrush ( self . parentTrackLabel . parentScene . parentView . parentMainWindow . fPalBlue . color ( role ) )
self . _cursorPosOnMoveStart = None
def yPos2trackIndex ( self , y ) :
""" 0 based """
pos = round ( y / SIZE_UNIT )
pos = min ( pos , len ( self . parentTrackLabel . parentScene . tracks ) - 1 )
return pos
def mouseMoveEvent ( self , event ) :
if self . _cursorPosOnMoveStart :
self . parentTrackLabel . setY ( max ( 0 , event . scenePos ( ) . y ( ) ) )
#super().mouseMoveEvent(event) #with this the sync between cursor and item is off.
def mousePressEvent ( self , event ) :
""" release gets only triggered when mousePressEvent was on the same item.
We don ' t need to worry about the user just releasing the mouse on this item " " "
self . _posBeforeMove = self . parentTrackLabel . pos ( )
self . _cursorPosOnMoveStart = QtGui . QCursor . pos ( )
self . _lineCursor = self . parentTrackLabel . lineEdit . cursor ( )
self . parentTrackLabel . mousePressEvent ( event )
#super().mousePressEvent(event) #with this in mouseMoveEvent does not work. IIRC because we do not set the movableFlag
def mouseReleaseEvent ( self , event ) :
newIndex = self . yPos2trackIndex ( self . parentTrackLabel . y ( ) ) #we need to save that first, right after this we reset the position
self . parentTrackLabel . setPos ( self . _posBeforeMove ) #In case the block was moved to a position where no track is (below the tracks) we just reset the graphics before anything happens. The user will never see this really
self . _posBeforeMove = None
self . _cursorPosOnMoveStart = None
api . moveTrack ( self . parentTrackLabel . exportDict [ " id " ] , newIndex )
class NameLineEdit ( QtWidgets . QLineEdit ) :
def __init__ ( self , parentTrackLabel ) :
super ( ) . __init__ ( " " )
self . parentTrackLabel = parentTrackLabel
self . setFrame ( False )
self . setMaxLength ( 25 )
self . setMinimumSize ( QtCore . QSize ( 0 , SIZE_UNIT ) )
self . setStyleSheet ( " background-color: rgba(0,0,0,0) " ) #transparent so we see the RectItem color
self . setReadOnly ( True )
self . setFocusPolicy ( QtCore . Qt . ClickFocus ) #nmo tab
self . editingFinished . connect ( self . sendToEngine )
self . returnPressed . connect ( self . enter )
def mousePressEvent ( self , event ) :
""" We also need to force this track as active """
event . accept ( ) #we need this for doubleClick
self . parentTrackLabel . parentScene . parentView . parentMainWindow . chooseCurrentTrack ( self . parentTrackLabel . exportDict )
#event.ignore() #send to parent instead
#super().mousePressEvent(event)
def mouseDoubleClickEvent ( self , event ) :
event . accept ( )
self . setReadOnly ( False )
def enter ( self ) :
self . sendToEngine ( )
def sendToEngine ( self ) :
self . setReadOnly ( True )
new = self . text ( )
if not new == self . parentTrackLabel . exportDict [ " sequencerInterface " ] [ " name " ] :
self . blockSignals ( True )
api . changeTrackName ( self . parentTrackLabel . exportDict [ " id " ] , new )
self . blockSignals ( False )
#def keyPressEvent(self, event):
# if event.key()) == QtCore.Qt.Key_Return:
# event.accept()
#
# else:
# event.ignore()
# super().keyPressEvent(event)
def update ( self , exportDict ) :
self . exportDict = exportDict
self . lineEdit . setText ( exportDict [ " sequencerInterface " ] [ " name " ] )
self . colorButton . setBrush ( QtGui . QColor ( exportDict [ " color " ] ) )
self . lengthMultiplicatorSpinBox . spinBox . blockSignals ( True )
self . lengthMultiplicatorSpinBox . spinBox . setValue ( int ( exportDict [ " patternLengthMultiplicator " ] ) )
self . lengthMultiplicatorSpinBox . spinBox . blockSignals ( False )
def mousePressEvent ( self , event ) :
self . parentScene . parentView . parentMainWindow . chooseCurrentTrack ( self . exportDict )
#event.ignore() #don't add that here. Will destroy mouseMove and Release events of child widgets
#def mouseReleaseEvent(self, event):
# event.
def mark ( self , boolean ) :
if boolean :
role = QtGui . QPalette . AlternateBase
else :
role = QtGui . QPalette . Base
c = self . parentScene . parentView . parentMainWindow . fPalBlue . color ( role )
self . setBrush ( c )
class Playhead ( QtWidgets . QGraphicsLineItem ) :
def __init__ ( self , parentScene ) :
super ( ) . __init__ ( 0 , 0 , 0 , 0 ) # (x1, y1, x2, y2)
self . parentScene = parentScene
p = QtGui . QPen ( )
p . setColor ( QtGui . QColor ( " red " ) )
p . setWidth ( 3 )
#p.setCosmetic(True)
self . setPen ( p )
api . callbacks . setPlaybackTicks . append ( self . setCursorPosition )
self . setZValue ( _zValuesRelativeToScene [ " playhead " ] )
def setCursorPosition ( self , tickindex , playbackStatus ) :
""" Set the playhead to the right position, but keep the viewport stable.
Shift the entire " page " if the cursor becomes invisible because its steps outside the viewport """
x = tickindex / self . parentScene . ticksToPixelRatio
self . setX ( x )
if playbackStatus : # api.duringPlayback:
scenePos = self . parentScene . parentView . mapFromScene ( self . pos ( ) )
cursorViewPosX = scenePos . x ( ) #the cursor position in View coordinates
width = self . parentScene . parentView . geometry ( ) . width ( )
if cursorViewPosX < = 0 or cursorViewPosX > = width : #"pageflip"
self . parentScene . parentView . horizontalScrollBar ( ) . setValue ( x )