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.
 
 
 
 
 
 

363 lines
15 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2021, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
This 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 Modules
#Third Party Modules
from PyQt5 import QtWidgets, QtCore, QtGui, QtOpenGL
#Template Modules
from template.engine.sequencer import MAXIMUM_TICK_DURATION
from template.qtgui.helper import stretchRect
from template.engine.duration import baseDurationToTraditionalNumber
#User modules
from .constantsAndConfigs import constantsAndConfigs
import engine.api as api
MAX_DURATION = MAXIMUM_TICK_DURATION / constantsAndConfigs.ticksToPixelRatio
class VelocityView(QtWidgets.QGraphicsView):
def __init__(self, mainWindow):
super().__init__(mainWindow)
self.mainWindow = mainWindow
viewport = QtWidgets.QOpenGLWidget()
viewportFormat = QtGui.QSurfaceFormat()
viewportFormat.setSwapInterval(0) #disable VSync
#viewportFormat.setSamples(2**8) #By default, the highest number of samples available is used.
viewportFormat.setDefaultFormat(viewportFormat)
viewport.setFormat(viewportFormat)
self.setViewport(viewport)
self.setAlignment(QtCore.Qt.AlignLeft|QtCore.Qt.AlignBottom)
self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) #This is also used for ScoreView
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.velocityScene = VelocityScene(self)
self.setScene(self.velocityScene)
self.setFixedHeight(150) #w, h #we need some headroom above 128 to make way for all the lines and scroolbars of qt
self.setSceneRect(QtCore.QRectF(0, -5, 1, 130)) #x, y, w, h
style = """
QScrollBar:horizontal {
border: 1px solid black;
}
QScrollBar::handle:horizontal {
background: #00b2b2;
}
QScrollBar:vertical {
border: 1px solid black;
}
QScrollBar::handle:vertical {
background: #00b2b2;
}
"""
self.setStyleSheet(style)
self.setLineWidth(0)
#def wheelEvent(self, event):
# """Eat mousewheel to the view doesn't scroll"""
# event.accept()
def zoom(self, factor:float):
"""Factor is absolute. We reset before setting the new scale"""
assert factor == constantsAndConfigs.zoomFactor
self.resetTransform()
self.scale(factor, 1)
def stretchXCoordinates(self, factor:float):
self.velocityScene.stretchXCoordinates(factor)
class VelocityScene(QtWidgets.QGraphicsScene):
"""This basically copies Score. There are many differences so we don't use shared code.
There is no selection, no shadows.
"""
def __init__(self, parentView):
super().__init__(parentView)
self.parentView = parentView
#Set color, otherwise it will be transparent in window managers or wayland that want that.
self.backColor = QtGui.QColor()
self.backColor.setNamedColor("#fdfdff")
self.setBackgroundBrush(self.backColor)
#debug lines top and bottom for reference
#self.addLine(0,127,5000,127)
#self.addLine(0,0,5000,0)
#Layers are created once and then kept alive.
self.layers = {}
for i in range(10):
l = VelocityLayer(self, i)
self.addItem(l)
l.setPos(0,0)
self.layers[i] = l #index:VelocityLayer
api.callbacks.newEvent.append(lambda eventDict: self.layers[eventDict["layer"]].newEvent(eventDict))
api.callbacks.deleteEvent.append(lambda eventDict: self.layers[eventDict["layer"]].deleteEvent(eventDict))
api.callbacks.eventByteTwoChanged.append(lambda eventDict: self.layers[eventDict["layer"]].eventByteTwoChanged(eventDict))
api.callbacks.eventPositionChanged.append(lambda eventDict: self.layers[eventDict["layer"]].eventPositionChanged(eventDict))
api.callbacks.layerChanged.append(lambda layer, data: self.layers[layer].redrawEvents(data))
api.callbacks.layerColorChanged.append(lambda layer, colorString: self.layers[layer].layerColorChanged(colorString))
api.callbacks.activeLayerChanged.append(self.activeLayerChanged)
def stretchXCoordinates(self, factor:float):
"""Reposition the items on the X axis.
Call goes through all parents/children, starting from ScoreView._stretchXCoordinates.
Docstring there."""
#The big structures have a fixed position at (0,0) and move its child items, like notes, internally
#Some items, like the cursor, move around, as a whole item, in the scene directly and need no stretchXCoordinates() themselves.
#Even if updated later they do this on the basis of tickFactor, which was adjusted at this point.
for layer in self.layers.values():
layer.stretchXCoordinates(factor)
def _hideAllLayers(self):
for layer in self.layers.values():
layer.setOpacity(1)
layer.setEnabled(True)
layer.hide()
def activeLayerChanged(self, layerIndex:int):
"""Callback for api.callbacks.activeLayerChanged"""
self._hideAllLayers()
self.layers[layerIndex].show()
def selectionChanged(self, listOfEngineIdsAndLayers:list):
"""[(id, layer)]"""
#Sort into layers
d = {0:[], 1:[], 2:[], 3:[], 4:[], 5:[], 6:[], 7:[], 8:[], 9:[], }
for (engineId, layerIndex) in listOfEngineIdsAndLayers:
d[layerIndex].append(engineId)
for layerIndex, itemIdList in d.items():
self.layers[layerIndex].selectionChanged(itemIdList)
def highlight(self,layerIndex:int, noteOnEngineId:int, state:bool):
self.layers[layerIndex].highlight(noteOnEngineId, state)
def wheelEvent(self, event):
"""
Contrary to other parts of the system event.ignore and accept actually mean something.
ignore will tell the caller to use the event itself, e.g. scroll.
"""
item = self.itemAt(event.scenePos(), self.parentView.transform())
if type(item) is Velocity:
super().wheelEvent(event) #send to child item
else:
event.ignore() #so the view scrolls
class VelocityLayer(QtWidgets.QGraphicsItem):
def __init__(self, parentScene, index):
super().__init__()
self.parentScene = parentScene
self.index = index
self.setFlag(QtWidgets.QGraphicsItem.ItemHasNoContents, True) #only child items. Without this we get notImplementedError: QGraphicsItem.paint() is abstract and must be overridden
self._fakeRect = QtCore.QRectF(0,0,0,0)
self.items = {} #engineId:Velocity-Item. Notes are twice in the dict, one id for note-on and one for note-off. Careful! items() is also a scene command to get items.
self.color = None #set in self.setColor
self.selectionColor = None #set in self.reactToColorCallback
def showItems(self):
for item in self.items.values():
item.show()
def selectionChanged(self, itemIdList):
"""Hide all items that are not selected, except when nothing is selected, then show all.
itemIdList contains both note on and note off ids, but we iterate over our OWN list
which has only note ons. This way we don't need to check if we handle 0x90 or discard 0x80
"""
if not itemIdList:
self.showItems()
else:
for engineId, item in self.items.items():
if engineId in itemIdList:
item.show()
else:
item.hide()
def boundingRect(self, *args):
return self._fakeRect
def stretchXCoordinates(self, factor:float):
for item in set(self.items.values()): #set to remove the same item from NoteOn and Off
item.setX(item.pos().x() * factor)
def layerColorChanged(self, color:str):
"""Out GUI calls
api.setLayerColor(self.index, self.color)
The engine already has the new color"""
self.color = QtGui.QColor(color)
if self.color.lightness() > 127: #between 0 (for black) and 255 (for white)
self.selectionColor = self.color.darker(150)
else:
self.selectionColor = self.color.lighter(200)
for item in self.items.values():
item.setBrush(self.color)
def redrawEvents(self, exportDict):
"""Clean redraw. Deletes all events of this layer.
Used only rarely because it is slow. E.g. on file load.
exportDict is sorted. No noteOff comes before its noteOn"""
assert exportDict["index"] == self.index
for item in set(self.items.values()): #set to remove the same item from NoteOn and Off
self.parentScene.removeItem(item)
self.items = {}
self.layerColorChanged(exportDict["color"])
for event in exportDict["events"]: # dictionary
self.newEvent(event)
def deleteEvent(self, eventDict):
"""Will be called twice in a row for a note on/off.
However, we only have one item for on/off and the off item was already deleted.
So we test if the item is still in the scene."""
if not eventDict["id"] in self.items:
return
item = self.items[eventDict["id"]]
del self.items[eventDict["id"]]
if not item.scene() is None:
self.parentScene.removeItem(item)
def newEvent(self, eventDict):
"""Used for all callbacks, be it live recording or file loading or anything in between"""
if not eventDict["status"] == 0x90:
return
item = self.liveNoteOn(eventDict)
assert item.parentItem() is self, (item.parentItem, self)
item.ids.add(eventDict["id"])
self.items[eventDict["id"]] = item
def liveNoteOn(self, eventDict):
"""Pitch is midi"""
pitch = eventDict["byte1"] #only for indexing
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
item = Velocity(self, self.index, eventDict["byte1"], eventDict["byte2"], self.color)
self._setItemCachedExportDict(item, eventDict)
item.setPos(x, 127) #Velocities go from 0 to 127.
return item
def _setItemCachedExportDict(self, item, eventDict):
item.noteOnExportDict = eventDict
def eventPositionChanged(self, eventDict):
"""e.g. move a pitch up and down"""
if not eventDict["status"] == 0x90:
return
item = self.items[eventDict["id"]]
self._setItemCachedExportDict(item, eventDict)
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
item.setX(x)
def eventByteTwoChanged(self, eventDict):
"""e.g. move a pitch up and down"""
if not eventDict["status"] == 0x90:
return
item = self.items[eventDict["id"]]
item.setVelocity(eventDict["byte2"])
self._setItemCachedExportDict(item, eventDict)
def highlight(self, noteOnEngineId:int, state:bool):
self.items[noteOnEngineId].highlight(state)
class Velocity(QtWidgets.QGraphicsRectItem):
def __init__(self, parentLayer, parentLayerIndex:int, pitch:int, velocity:int, color):
"""Position in the layer/scene is not calculated in the item itself but outside"""
super().__init__(0, -velocity, 15, velocity) #x, y, w, h
self.setParentItem(parentLayer)
self.parentLayerIndex = parentLayerIndex
self.parentLayer = parentLayer
self.byte1 = pitch
self.byte2 = velocity
pen = QtGui.QPen(QtCore.Qt.SolidLine)
pen.setCosmetic(True)
self.setPen(pen)
self.setAcceptHoverEvents(True)
self.setBrush(color)
self.ids = set() #IDs are set by the creating function. Most have only one, but notes have two
self.noteOnExportDict = None #has position, id etc.
self.numberLabel = QtWidgets.QGraphicsSimpleTextItem()
self.numberLabel.setParentItem(self)
self.numberLabel.setScale(0.8)
self.numberLabel.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations, True)
self.updateNumberLabel()
def updateNumberLabel(self):
self.numberLabel.setText(str(self.byte2))
self.numberLabel.setPos(0, -self.byte2-10)
def setVelocity(self, value:int):
self.byte2 = value
self.setRect(0, -value, 15, value)
self.updateNumberLabel()
def stretchXCoordinates(self, factor:float):
stretchRect(self, factor)
def highlight(self, state:bool):
if state:
self.setBrush(self.parentLayer.selectionColor)
else:
self.setBrush(self.parentLayer.color)
def hoverEnterEvent(self, event):
self.parentLayer.parentScene.parentView.mainWindow.highlight(self.parentLayerIndex, self.noteOnExportDict["id"], True) #make a roundtrip over the mainwindow. In the end our own highlight will be called, but also the velocityView and future items are easily possible
super().hoverEnterEvent(event)
def hoverLeaveEvent(self, event):
self.parentLayer.parentScene.parentView.mainWindow.highlight(self.parentLayerIndex, self.noteOnExportDict["id"], False)
super().hoverLeaveEvent(event)
def wheelEvent(self, event):
event.accept()
if event.delta() > 0:
self.changeVelocity(2)
elif event.delta() < 0:
self.changeVelocity(-2)
def changeVelocity(self, relativeValue:int):
api.changeVelocitiesRelative([self.noteOnExportDict["id"]], relativeValue)