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.
816 lines
39 KiB
816 lines
39 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2019, 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/>.
|
|
"""
|
|
|
|
from time import time
|
|
import engine.api as api #Session is already loaded and created, no duplication.
|
|
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,
|
|
"switch":4,
|
|
"barline":5,
|
|
"playhead":90,
|
|
}
|
|
|
|
class SongEditor(QtWidgets.QGraphicsScene):
|
|
def __init__(self, parentView):
|
|
super().__init__()
|
|
self.parentView = parentView
|
|
|
|
#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)
|
|
|
|
#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]
|
|
#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):
|
|
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():
|
|
track.updateSwitchVisibility(requestAmountOfMeasures=requestAmountOfMeasures-1)
|
|
track.updateStaffLines(requestAmountOfMeasures-1)
|
|
|
|
def callback_scoreChanged(self, 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)
|
|
else:
|
|
barline.setPen(self.normalPen)
|
|
|
|
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.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)
|
|
self._markerLine.setY(0) #x is set in mousePressEvent
|
|
self._markerLine.setZValue(_zValuesRelativeToScene["playhead"])
|
|
self._markerLine.hide()
|
|
|
|
def _setColors(self, exportDict):
|
|
c = QtGui.QColor(exportDict["color"])
|
|
self.currentColor = c
|
|
|
|
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)
|
|
|
|
self.updateSwitchVisibility(exportDict["numberOfMeasures"])
|
|
|
|
def updateMetaData(self, exportDict):
|
|
"""Color and Transposition status.
|
|
Does not get called on track structure change."""
|
|
self._setColors(exportDict)
|
|
|
|
for switch in self.switches.values():
|
|
switch.setBrush(self.currentColor)
|
|
switch.setScaleTransposeColor(self.labelColor)
|
|
switch.setHalftoneTransposeColor(self.labelColor)
|
|
|
|
def updateStaffLines(self, requestAmountOfMeasures):
|
|
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)
|
|
switch.setX(position * SIZE_UNIT)
|
|
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"]
|
|
for position, switch in self.switches.items():
|
|
if position < requestAmountOfMeasures and position in structure:
|
|
switch.show()
|
|
else:
|
|
switch.hide() #Not delete because this may be just a temporary reduction of measures
|
|
switch.scaleTransposeOff()
|
|
|
|
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 scenePos2switchPosition(self, x):
|
|
return int(x / SIZE_UNIT)
|
|
|
|
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:
|
|
assert not self._mousePressOn
|
|
position = self.scenePos2switchPosition(event.scenePos().x()) #measure number 0 based
|
|
self._markerLine.setX(position * SIZE_UNIT )
|
|
|
|
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
|
|
assert result
|
|
|
|
#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 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
|
|
measuresPerGroup = self.parentScene.measuresPerGroupCache
|
|
offset = position % measuresPerGroup
|
|
startMeasureForGroup = position - offset
|
|
endMeasureExclusive = startMeasureForGroup + measuresPerGroup
|
|
|
|
listOfLabelsAndFunctions = [
|
|
(QtCore.QCoreApplication.translate("SongStructure", "Insert empty group before this one").format(measuresPerGroup), lambda: api.insertSilence(howMany=measuresPerGroup, beforeMeasureNumber=startMeasureForGroup)),
|
|
(QtCore.QCoreApplication.translate("SongStructure", "Delete whole group").format(measuresPerGroup), 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 mouseMoveEvent(self, event):
|
|
position = self.scenePos2switchPosition(event.scenePos().x()) #measure number 0 based
|
|
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)
|
|
rect.setRight(SIZE_UNIT)
|
|
else:
|
|
right = (position - self._mousePressOn[2]) * SIZE_UNIT + SIZE_UNIT
|
|
rect.setRight(right)
|
|
rect.setLeft(0)
|
|
|
|
self._markerLine.setRect(rect)
|
|
self._markerLine.show()
|
|
else:
|
|
self._markerLine.hide()
|
|
|
|
|
|
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."""
|
|
def __init__(self, parentTrackStructure, position):
|
|
self.parentTrackStructure = parentTrackStructure
|
|
self.position = position
|
|
super().__init__(0,0,SIZE_UNIT,SIZE_UNIT)
|
|
|
|
self.setAcceptHoverEvents(True)
|
|
|
|
self.setZValue(_zValuesRelativeToScene["switch"])
|
|
|
|
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 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:TrackStructures
|
|
|
|
self._cachedExportDictsInOrder = []
|
|
|
|
api.callbacks.numberOfTracksChanged.append(self.callback_numberOfTracksChanged)
|
|
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
|
|
api.callbacks.exportCacheChanged.append(self.cacheExportDict)
|
|
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_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 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)
|
|
|
|
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.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.colorButton = TrackLabel.ColorPicker(parentTrackLabel=self)
|
|
self.colorButton.setParentItem(self)
|
|
self.colorButton.setPos(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(2*SIZE_UNIT+3,0)
|
|
|
|
self.setFlag(self.ItemIgnoresTransformations)
|
|
|
|
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.lineEdit.setText(exportDict["sequencerInterface"]["name"])
|
|
self.exportDict = exportDict
|
|
self.colorButton.setBrush(QtGui.QColor(exportDict["color"]))
|
|
|
|
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)
|
|
|