#! /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 . """ 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)