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.
 
 

1469 lines
72 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022, 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")
#Standard Library
from fractions import Fraction
from time import time
#Third Party
from PyQt5 import QtCore, QtGui, QtWidgets
#Template
import template.qtgui.helper as helper
#Our modules
import engine.api as api #Session is already loaded and created, no duplication.
SIZE_UNIT = 30 #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,
"group" : 6,
"switch":7,
"barlineGroupHighlight":9,
"playhead":90,
}
class SongEditor(QtWidgets.QGraphicsScene):
def __init__(self, parentView):
super().__init__()
self.parentView = parentView
self.statusMessage = self.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
#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._groupRectangles = []
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()
self.currentHoverStep = None #either a Step() object or None. Only set when hovering active steps.
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)
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. Also groups and visibility."""
toDelete = set(self.tracks.keys())
self.trackOrder = []
groupsSeen = set() #check if we already know this group
for grect in self._groupRectangles:
self.removeItem(grect) #group rectangles are direct children of the scene. delete them here.
self._groupRectangles = []
groupOffset = 0 #pixels. It is a positive/absolute value
hiddenOffsetCounter = 0 #counts hidden tracks in pixels. It is a positive/absolute value
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"])
if exportDict["group"]:
if not exportDict["group"] in groupsSeen: #first encounter
groupsSeen.add(exportDict["group"])
groupRect = QtWidgets.QGraphicsRectItem(0,0, exportDict["numberOfMeasures"]*SIZE_UNIT, SIZE_UNIT)
groupRect.trackGroup = exportDict["group"] #add a marker for double clicks, so that we don't have to create a whole new class.
role = QtGui.QPalette.Window
c = self.parentView.parentMainWindow.fPalBlue.color(role)
groupRect.setBrush(c)
self.addItem(groupRect)
groupRect.setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter)
groupRect.setZValue(_zValuesRelativeToScene["group"])
self._groupRectangles.append(groupRect)
groupOffset = len(groupsSeen) * SIZE_UNIT
if exportDict["visible"]:
self.tracks[exportDict["id"]].show()
else:
self.tracks[exportDict["id"]].hide()
hiddenOffsetCounter += SIZE_UNIT
self.trackOrder.append(self.tracks[exportDict["id"]])
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter)
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 + groupOffset - hiddenOffsetCounter
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()
#Adjust Group Header Rectangles
for groupRect in self._groupRectangles:
r = groupRect.rect()
r.setWidth((requestAmountOfMeasures-1) * SIZE_UNIT)
groupRect.setRect(r)
#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)
def mouseDoubleClickEvent(self, event):
item = self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())
event.ignore() #send to child widget
if item:
try:
item.trackGroup
api.setGroupVisible(item.trackGroup)
except:
pass
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.statusMessage = self.parentScene.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
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"]+1) #It is not possible to have this in front of barlines AND the switches. Barlines need to be below switches for multiplied-patterns. But we want to "erase" the switches
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)
switch.setStepDelayColor(self.labelColor)
switch.setAugmentationFactorColor(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"]
whichPatternsAreStepDelayed = self.exportDict["whichPatternsAreStepDelayed"]
whichPatternsHaveAugmentationFactor = self.exportDict["whichPatternsHaveAugmentationFactor"]
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)
vis = self.exportDict["visible"]
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()
if position in whichPatternsAreStepDelayed:
switch.setStepDelay(whichPatternsAreStepDelayed[position])
else:
switch.stepDelayOff()
if position in whichPatternsHaveAugmentationFactor:
switch.setAugmentationFactor(whichPatternsHaveAugmentationFactor[position])
else:
switch.augmentationFactorOff()
if not vis:
switch.hide()
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.clearSwitchGroupModifications(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, sendChangeToApi=True)
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()
self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Empty Measure: Left click to activate. Middle click to show as shadows in current pattern. Right click for measure group options.")) #Yes, this is the track. Empty measures are not objects.
#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, sendChangeToApi=True)
def hoverLeaveEvent(self, event):
self.statusMessage("")
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.
This is directly for the switches. We also defer a call to the name label"""
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.statusMessage = self.parentTrackStructure.parentScene.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
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.75)
self.scaleTransposeGlyph.setPos(2,0)
self.scaleTransposeGlyph.setBrush(self.parentTrackStructure.labelColor)
self.scaleTransposeGlyph.hide()
self.scaleTranspose = 0 #default engine value, safe to assume that it will never change as default.
self.halftoneTransposeGlyph = QtWidgets.QGraphicsSimpleTextItem("")
self.halftoneTransposeGlyph.setParentItem(self)
self.halftoneTransposeGlyph.setScale(0.75)
self.halftoneTransposeGlyph.setPos(2,0) #We expect that only one of the transpose variants will be used. Therefore we place them on the same coordinates, because there is not enough space for 4 mods.
self.halftoneTransposeGlyph.setBrush(self.parentTrackStructure.labelColor)
self.halftoneTransposeGlyph.hide()
self.halftoneTranspose = 0 #default engine value, safe to assume that it will never change as default.
self.stepDelayGlyph = QtWidgets.QGraphicsSimpleTextItem("")
self.stepDelayGlyph.setParentItem(self)
self.stepDelayGlyph.setScale(0.75)
self.stepDelayGlyph.setPos(1,10)
self.stepDelayGlyph.setBrush(self.parentTrackStructure.labelColor)
self.stepDelayGlyph.hide()
self.stepDelay = 0 #default engine value, safe to assume that it will never change as default.
self.augmentationFactorGlyph = QtWidgets.QGraphicsSimpleTextItem("")
self.augmentationFactorGlyph.setParentItem(self)
self.augmentationFactorGlyph.setScale(0.75)
self.augmentationFactorGlyph.setPos(1,20)
self.augmentationFactorGlyph.setBrush(self.parentTrackStructure.labelColor)
self.augmentationFactorGlyph.hide()
self.augmentationFactor = 0 #default engine value, safe to assume that it will never change as default.
def stretch(self, factor):
"""factor assumes relative to SIZE_UNIT"""
r = self.rect()
r.setRight(SIZE_UNIT * factor)
self.setRect(r)
#Scale Transpose
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" #because - is added automatically
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 increaseScaleTranspose(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferScaleTranspose += 1
self._setScaleTransposeLabel(self._bufferScaleTranspose)
def decreaseScaleTranspose(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferScaleTranspose -= 1
self._setScaleTransposeLabel(self._bufferScaleTranspose)
#Halftone Transpose
def setHalftoneTranspose(self, value):
self.halftoneTranspose = value
self._setHalftoneTransposeLabel(value)
def _setHalftoneTransposeLabel(self, value):
text = ("+" if value > 0 else "") + str(value) + "h" #because - is added automatically
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 increaseHalftoneTranspose(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferHalftoneTranspose += 1
self._setHalftoneTransposeLabel(self._bufferHalftoneTranspose)
def decreaseHalftoneTranspose(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferHalftoneTranspose -= 1
self._setHalftoneTransposeLabel(self._bufferHalftoneTranspose )
#Step Delay
def setStepDelay(self, value):
self.stepDelay = value
self._setStepDelayLabel(value)
def _setStepDelayLabel(self, value):
text = ("+" if value > 0 else "") + "d" + str(value) #because - is added automatically
self.stepDelayGlyph.setText(text)
self.stepDelayGlyph.show()
def setStepDelayColor(self, c):
self.stepDelayGlyph.setBrush(c)
def stepDelayOff(self):
self.stepDelayGlyph.setText("")
#self.stepDelayGlyph.hide()
self.stepDelay = 0
self._bufferStepDelay = 0
def increaseStepDelay(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferStepDelay += 1
self._setStepDelayLabel(self._bufferStepDelay)
def decreaseStepDelay(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferStepDelay -= 1
self._setStepDelayLabel(self._bufferStepDelay)
#Augmentation Factor
def setAugmentationFactor(self, value):
self.augmentationFactor = value
self._setAugmentationFactorLabel(Fraction(value))
def _setAugmentationFactorLabel(self, value):
text = "×" + str(value)
self.augmentationFactorGlyph.setText(text)
self.augmentationFactorGlyph.show()
def setAugmentationFactorColor(self, c):
self.augmentationFactorGlyph.setBrush(c)
def augmentationFactorOff(self):
self.augmentationFactorGlyph.setText("")
#self.augmentationFactorGlyph.hide()
self.augmentationFactor = 1.0
self._bufferAugmentationFactor = 1.0
def increaseAugmentationFactor(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferAugmentationFactor *= 2
self._setAugmentationFactorLabel(Fraction(self._bufferAugmentationFactor))
def decreaseAugmentationFactor(self):
"""By 1. Convenience function to make code in mainWindow cleaner"""
self._bufferAugmentationFactor /= 2
self._setAugmentationFactorLabel(Fraction(self._bufferAugmentationFactor))
#Events
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):
"""Only active switches"""
#self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Measure: Left click to deactivate. Middle click to show as shadows in current pattern. Shift+MouseWheel for half tone transposition. Alt+MouseWheel for in-scale transposition. Right click for measure group options."))
self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Measure: Left click to deactivate. Middle click to show as shadows in current pattern. Right click for measure group options. Read the Edit menu for advanced modifications while hovering."))
self._bufferScaleTranspose = self.scaleTranspose
self._bufferHalftoneTranspose = self.halftoneTranspose
self._bufferStepDelay = self.stepDelay
self._bufferAugmentationFactor = self.augmentationFactor
self.parentTrackStructure.parentScene.currentHoverStep = self
def hoverLeaveEvent(self, event):
"""only triggered when active/shown.
When leaving a modified step it will send all changes to the api.
"""
self.parentTrackStructure.parentScene.currentHoverStep = None
self.statusMessage("")
event.accept()
#The api callback resets our buffer and values.
#That is fine except if we want to register multiple changes at once. Therefore we first copy our buffers and send the copies.
bscale = -1*self._bufferScaleTranspose
bhalftone = self._bufferHalftoneTranspose
bdelay = self._bufferStepDelay
baugment = self._bufferAugmentationFactor
#Scale Transpose. Independent of Halftone Transpose
if not bscale == self.scaleTranspose:
api.setSwitchScaleTranspose(self.parentTrackStructure.exportDict["id"], self.position, bscale) #we flip the polarity here. The receiving flip is done in the callback.
#new transpose/buffer gets set via callback
if bscale == 0:
self.scaleTransposeOff()
#Halftone Transpose. Independent of Scale Transpose
if not bhalftone == self.halftoneTranspose:
api.setSwitchHalftoneTranspose(self.parentTrackStructure.exportDict["id"], self.position, bhalftone) #half tone transposition is not flipped
#new transpose/buffer gets set via callback
if bhalftone == 0:
self.halftoneTransposeOff()
#Step Delay. Also independent.
if not bdelay == self.stepDelay:
api.setSwitchStepDelay(self.parentTrackStructure.exportDict["id"], self.position, bdelay)
#new value/buffer gets set via callback
if bdelay == 0:
self.stepDelayOff()
#Augmentation Factor. Interconnected... nah, just joking. Independent of the other stuff.
if not baugment == self.augmentationFactor:
api.setSwitchAugmentationsFactor(self.parentTrackStructure.exportDict["id"], self.position, baugment)
#new value/buffer gets set via callback
if baugment == 1.0:
self.augmentationFactorOff()
def deprecated_wheelEvent(self, event):
#We now use dedicated keyboard shortcuts and not the mousewheel anymore.
#See main window menu actions
"""Does not get triggered when switch is off.
This buffers until hoverLeaveEvent and then the new value is sent in self.hoverLeaveEvent
We want to keep normal scrolling with the mousewheel, therefore both transpose functions
need an additional key. Otherwise we get scroll on measures that are off and transpose
on measures that are active, which is very confusing.
"""
if QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.ShiftModifier: #half tone transposition
event.accept()
if event.delta() > 0:
self._bufferHalftoneTranspose = min(+24, self._bufferHalftoneTranspose+1)
else:
self._bufferHalftoneTranspose = max(-24, self._bufferHalftoneTranspose-1)
self._setHalftoneTransposeLabel(self._bufferHalftoneTranspose)
elif QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.AltModifier: #scale transposition
event.accept()
if event.delta() > 0:
self._bufferScaleTranspose = min(+7, self._bufferScaleTranspose+1)
else:
self._bufferScaleTranspose = max(-7, self._bufferScaleTranspose-1)
self._setScaleTransposeLabel(self._bufferScaleTranspose)
#Step Delay and Augmentation Factor are not done via mousewheel. There are not enough modifier keys left over :)
#They are instead handled by menu actions directly, in cooperations with our hover callbacks.
else: #normal scroll or zoom.
event.ignore()
#super.wheelEvent(event)
class TrackLabelEditor(QtWidgets.QGraphicsScene):
"""Only the track labels: names, colors, groups"""
def __init__(self, parentView):
super().__init__()
self.parentView = parentView
self.statusMessage = self.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
self.tracks = {} #TrackID:TrackLabel
self.groups = [] #GroupLabel()
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):
groupsSeen = set() #check if we already know this group
toDelete = set(self.tracks.keys())
self._cachedExportDictsInOrder = exportDictList
width = self.parentView.geometry().width()
#clean group labels. Will be recreated below
for group in self.groups:
self.removeItem(group)
groupOffset = 0 #pixels. It is a positive/absolute value
hiddenOffsetCounter = 0 #counts hidden tracks in pixels. It is a positive/absolute value
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"]])
if exportDict["group"]:
if not exportDict["group"] in groupsSeen: #first encounter
groupsSeen.add(exportDict["group"])
grouplabel = GroupLabel(parentScene=self, width=width, height=SIZE_UNIT, name=exportDict["group"], visible=exportDict["visible"])
self.addItem(grouplabel)
self.groups.append(grouplabel)
grouplabel.setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter) #group offset is still "above" the current group. If this is a group itself the offset will be extended just belowt to make room for ourselves.
groupOffset = len(groupsSeen) * SIZE_UNIT
if exportDict["visible"]:
self.tracks[exportDict["id"]].show()
else:
self.tracks[exportDict["id"]].hide()
hiddenOffsetCounter += SIZE_UNIT
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET + groupOffset - hiddenOffsetCounter)
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, sendChangeToApi=True)
self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT + groupOffset - hiddenOffsetCounter
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 tellApiToCreateNewGroupForTrack(self, trackId):
title = QtCore.QCoreApplication.translate("TrackLabelContext", "Group Name")
info = QtCore.QCoreApplication.translate("TrackLabelContext", "Create a new group by name")
result, ok = QtWidgets.QInputDialog.getText(self.parentView, title, info) #parent, titlebar, info-text
if ok:
api.setTrackGroup(trackId, str(result))
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 item:
return
if type(item) is TrackLabel:
exportDict = item.exportDict.copy()
elif type(item.parentItem()) is TrackLabel:
exportDict = item.parentItem().exportDict.copy()
else:
return None
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
#Preare both on and off variants so we have a static string translation
stepDelayOnEntry = (QtCore.QCoreApplication.translate("TrackLabelContext", "Step Delay Wrap-Around: turn on"), lambda: api.changeTrackStepDelayWrapAround(exportDict["id"], True))
stepDelayOffEntry = (QtCore.QCoreApplication.translate("TrackLabelContext", "Step Delay Wrap-Around: turn off"), lambda: api.changeTrackStepDelayWrapAround(exportDict["id"], False))
stepDelayUse = stepDelayOffEntry if exportDict["stepDelayWrapAround"] else stepDelayOnEntry
repeatDiminishedEntryOn = (QtCore.QCoreApplication.translate("TrackLabelContext", "Repeat Diminished Pattern in itself: turn on"), lambda: api.changeTrackRepeatDiminishedPatternInItself(exportDict["id"], True))
repeatDiminishedEntryOff = (QtCore.QCoreApplication.translate("TrackLabelContext", "Repeat Diminished Pattern in itself: turn off"), lambda: api.changeTrackRepeatDiminishedPatternInItself(exportDict["id"], False))
repeatDiminishedEntryUse = repeatDiminishedEntryOff if exportDict["repeatDiminishedPatternInItself"] else repeatDiminishedEntryOn
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"])),
stepDelayUse,
repeatDiminishedEntryUse,
(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)
#Add a submenu for track groups. Will call the api which will send us a callback to reorder the tracks.
groupMenu = menu.addMenu(QtCore.QCoreApplication.translate("TrackLabelContext", "Group"))
newGroupAction = QtWidgets.QAction(QtCore.QCoreApplication.translate("TrackLabelContext", "New Group"), groupMenu)
groupMenu.addAction(newGroupAction)
newGroupAction.triggered.connect(lambda: self.tellApiToCreateNewGroupForTrack(exportDict["id"]))
if exportDict["group"]:
removeGroupAction = QtWidgets.QAction(QtCore.QCoreApplication.translate("TrackLabelContext", "Remove from ")+exportDict['group'], groupMenu)
groupMenu.addAction(removeGroupAction)
removeGroupAction.triggered.connect(lambda: api.setTrackGroup(exportDict["id"], "")) #empty string = no group
groupMenu.addSeparator()
#Offer existing groups
for groupString in api.getGroups():
grpAction = QtWidgets.QAction(groupString, groupMenu)
groupMenu.addAction(grpAction)
midiChannelCommand = lambda discard, grpArg=groupString: api.setTrackGroup(exportDict["id"], grpArg) #discard parameter given by QAction
if exportDict["group"] == groupString:
grpAction.setEnabled(False)
grpAction.triggered.connect(midiChannelCommand)
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
self.parentView.parentMainWindow.setFocus()
menu.exec_(pos)
class TrackLabel(QtWidgets.QGraphicsRectItem):
"""One track label with color, name line edit etc.
Only gets the data when update() is called.
"""
def __init__(self, parentScene, width, height):
super().__init__(0, 0, width, height)
self.parentScene = parentScene
self.exportDict = None #set in self.update
self.statusMessage = self.parentScene.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
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.lengthMultiplicatorSpinBox = TrackLabel.lengthMultiplicatorSpinBox(parentTrackLabel=self)
self.lengthMultiplicatorSpinBox.setParentItem(self)
self.colorButton = TrackLabel.ColorPicker(parentTrackLabel=self)
self.colorButton.setParentItem(self)
self.lineEdit = TrackLabel.NameLineEdit(parentTrackLabel=self)
self.label = QtWidgets.QGraphicsProxyWidget()
self.label.setWidget(self.lineEdit)
self.label.setParentItem(self)
self.positionButtons()
def positionButtons(self, inGroup:bool=False):
"""Used for init, but also for update to show if we are in a track-group or not by
indentation."""
if inGroup:
offsetInSizeUnit = SIZE_UNIT
else:
offsetInSizeUnit = 0
self.positioningHandle.setPos(0,0)
self.lengthMultiplicatorSpinBox.setPos(SIZE_UNIT,2)
self.colorButton.setPos(3*SIZE_UNIT+offsetInSizeUnit, 3)
self.label.setPos(4*SIZE_UNIT+3+offsetInSizeUnit,0)
class lengthMultiplicatorSpinBox(QtWidgets.QGraphicsProxyWidget):
def __init__(self, parentTrackLabel):
super().__init__()
self.parentTrackLabel = parentTrackLabel
self.setAcceptHoverEvents(True)
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 hoverEnterEvent(self, event):
self.parentTrackLabel.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Measure length multiplicator. Enter number or spin the mouse wheel to change."))
def hoverLeaveEvent(self, event):
self.parentTrackLabel.statusMessage("")
def spinBoxValueChanged(self):
self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict, sendChangeToApi=True)
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"))
self.setAcceptHoverEvents(True)
self.setToolTip(QtCore.QCoreApplication.translate("TrackLabel", "change track color"))
def hoverEnterEvent(self, event):
self.parentTrackLabel.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Left click to change track color"))
def hoverLeaveEvent(self, event):
self.parentTrackLabel.statusMessage("")
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict, sendChangeToApi=True)
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.setToolTip(QtCore.QCoreApplication.translate("TrackLabel", "grab and move to reorder tracks"))
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.5)
self.arrowLabel.setPos(5,2) #try to get the center
role = QtGui.QPalette.Text
self.arrowLabel.setBrush(self.parentTrackLabel.parentScene.parentView.parentMainWindow.fPalBlue.color(role))
self._cursorPosOnMoveStart = None
self.setAcceptHoverEvents(True)
def hoverEnterEvent(self, event):
self.parentTrackLabel.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Hold left mouse button and move to reorder tracks"))
def hoverLeaveEvent(self, event):
self.parentTrackLabel.statusMessage("")
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):
"""Move track up and down. Started by mousePressEvent and finalized by mouseReleaseEvent"""
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.parentTrackLabel.setZValue(self.parentTrackLabel.zValue()+1) #in front of other tracks
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.parentTrackLabel.setZValue(self.parentTrackLabel.zValue()-1) #revert mousePressEvent's +1
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 enterEvent(self, event):
self.parentTrackLabel.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Click to select track. Double click to change track name"))
def leaveEvent(self, event):
self.parentTrackLabel.statusMessage("")
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, sendChangeToApi=True)
#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.positionButtons(inGroup=bool(exportDict["group"]))
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, sendChangeToApi=True)
#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 GroupLabel(QtWidgets.QGraphicsRectItem):
"""Compatible with TrackLabel but stripped down:
No name change, no color, no multiplicator. But
a name that you can drag around
Group Labels get deleted and recreated on each change.
"""
def __init__(self, parentScene, width, height, name, visible):
super().__init__(0, 0, width, height)
self.parentScene = parentScene
self.statusMessage = self.parentScene.parentView.parentMainWindow.statusBar().showMessage #a version with the correct path of this is in every class of Patroneo
self.group = name
self.visible = visible #if that changes it will change only on creation of a GroupLabel instance
self.setPen(QtGui.QPen(QtCore.Qt.NoPen))
self.setFlag(self.ItemIgnoresTransformations) #zoom will repostion but not make the font bigger.
c = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.Window)
self.setBrush(c)
#Child widgets. SIZE_UNIT is used as positioning block. Multiply yourself :)
self._duringGroupMove = False
self.positioningHandle = GroupLabel.PositioningHandle(parentGroupLabel=self)
self.positioningHandle.setParentItem(self)
self.positioningHandle.setPos(0,0)
if visible:
name = "" + name
else:
name = "" + name
self.qLabel = QtWidgets.QLabel(name)
self.label = QtWidgets.QGraphicsProxyWidget()
self.label.setWidget(self.qLabel)
self.label.setParentItem(self)
self.label.setPos(3*SIZE_UNIT+3,0)
self.qLabel.setMinimumSize(QtCore.QSize(0, SIZE_UNIT))
self.qLabel.setStyleSheet("background-color: rgba(0,0,0,0)") #transparent so we see the RectItem color
self.setAcceptHoverEvents(True)
def hoverEnterEvent(self, event):
self.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Track Group: Double Click to show or hide. You can also double click the empty group spacers above the tracks."))
def hoverLeaveEvent(self, event):
self.statusMessage("")
def mouseDoubleClickEvent(self,event):
"""Without this no PositionHandle mouseMove and mouse Release events!!!
Also no double click"""
#super().mousePressEvent(event)
if not self.positioningHandle._cursorPosOnMoveStart: #during group move
api.setGroupVisible(self.group) #calling with one parameter toggles visibility.
#def mouseDoubleClickEvent(self, event):
# event.accept()
class PositioningHandle(QtWidgets.QGraphicsEllipseItem):
def __init__(self, parentGroupLabel):
super().__init__(0,0,SIZE_UNIT-2,SIZE_UNIT-2)
self.setToolTip(QtCore.QCoreApplication.translate("GroupLabel", "grab and move to reorder groups"))
self.parentGroupLabel = parentGroupLabel
self.setPen(QtGui.QPen(QtCore.Qt.NoPen))
role = QtGui.QPalette.ToolTipBase
c = self.parentGroupLabel.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.5)
self.arrowLabel.setPos(5,2) #try to get the center
role = QtGui.QPalette.Text
self.arrowLabel.setBrush(self.parentGroupLabel.parentScene.parentView.parentMainWindow.fPalBlue.color(role))
#self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, bool) #!!! Prevents double click to hide.
#self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, bool) #!!! Prevents double click to hide.
self._cursorPosOnMoveStart = None
self.setAcceptHoverEvents(True)
def hoverEnterEvent(self, event):
self.parentGroupLabel.statusMessage(QtCore.QCoreApplication.translate("Statusbar", "Hold left mouse button and move to reorder track groups"))
def hoverLeaveEvent(self, event):
self.parentGroupLabel.statusMessage("")
def yPos2trackIndex(self, y):
"""0 based"""
pos = round(y / SIZE_UNIT)
pos = min(pos, len(self.parentGroupLabel.parentScene.tracks)-1)
return pos
def mouseMoveEvent(self, event):
if self._cursorPosOnMoveStart:
self.parentGroupLabel.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.parentGroupLabel.pos()
self._cursorPosOnMoveStart = QtGui.QCursor.pos()
self.parentGroupLabel.setZValue(self.parentGroupLabel.zValue()+1) #in front of other groups
#self.parentGroupLabel.mousePressEvent(event) #This blocks mouseMOveEvent
#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.parentGroupLabel.y()) #we need to save that first, right after this we reset the position
self.parentGroupLabel.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.parentGroupLabel.setZValue(self.parentGroupLabel.zValue()-1) #revert mousePressEvent's + 1
self._posBeforeMove = None
self._cursorPosOnMoveStart = None
api.moveGroup(self.parentGroupLabel.group, newIndex)
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)
#PlayHead height is set in SongEditor.callback_numberOfTracksChanged.
#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.
PlayHead height is set in SongEditor.callback_numberOfTracksChanged.
"""
x = int(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)