#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), more specifically its template base application. Laborejo2 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 . """ from typing import Iterable, Callable, Tuple from PyQt5 import QtCore, QtGui, QtWidgets import engine.api as api import template.engine.pitch as pitch from template.qtgui.helper import QHLine from .constantsAndConfigs import constantsAndConfigs from .designer.tickWidget import Ui_tickWidget from sys import maxsize class CombinedTickWidget(QtWidgets.QFrame): def __init__(self): super().__init__() self.setFrameShape(QtWidgets.QFrame.Box) self.setFrameShadow(QtWidgets.QFrame.Sunken) self.horizontalLayout_3 = QtWidgets.QHBoxLayout(self) self.horizontalLayout_3.setContentsMargins(3, 0, 3, 0) self.horizontalLayout_3.setSpacing(0) self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.upbeatSpinBox = QtWidgets.QSpinBox(self) self.upbeatSpinBox.setPrefix("") self.upbeatSpinBox.setMinimum(0) self.upbeatSpinBox.setMaximum(999999999) self.upbeatSpinBox.setObjectName("upbeatSpinBox") self.horizontalLayout_3.addWidget(self.upbeatSpinBox) self.callTickWidget = QtWidgets.QPushButton(self) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.callTickWidget.sizePolicy().hasHeightForWidth()) self.callTickWidget.setSizePolicy(sizePolicy) self.callTickWidget.setMaximumSize(QtCore.QSize(25, 16777215)) self.callTickWidget.setFlat(False) self.callTickWidget.setObjectName("callTickWidget") self.callTickWidget.setText("𝅘𝅥𝅮 ") self.horizontalLayout_3.addWidget(self.callTickWidget) self.callTickWidget.clicked.connect(self.callClickWidgetForUpbeat) self.setFocusPolicy(0) #no focus self.callTickWidget.setFocusPolicy(0) #no focus self.valueChanged = self.upbeatSpinBox.valueChanged def setMinimum(self, value): self.upbeatSpinBox.setMinimum(value) def setMaximum(self, value): self.upbeatSpinBox.setMaximum(value) def setValue(self, value): self.upbeatSpinBox.setValue(value) def value(self): """Make this widget behave like a spinbox signal""" return self.upbeatSpinBox.value() def callClickWidgetForUpbeat(self): dialog = TickWidget(self, initValue = self.upbeatSpinBox.value()) self.upbeatSpinBox.setValue(dialog.ui.ticks.value()) class TickWidget(QtWidgets.QDialog): def __init__(self, mainWindow, initValue = 0): super().__init__(mainWindow) #Set up the user interface from Designer. self.ui = Ui_tickWidget() self.ui.setupUi(self) #self.ui.ticks.setValue(initValue) self.ui.ticks.setValue(0) #TODO: easier to drawLabel this way. change back to given value when drawLabel is autogenerated and does not work by keeping track anymore. self.ui.ok.clicked.connect(lambda: self.done(True)) self.ui.cancel.clicked.connect(lambda: self.done(False)) self.ui.reset.clicked.connect(self.reset) self.ui.durationLabel.setText("") self.clickedSoFar = [] #keep track self.ui.D1.clicked.connect(lambda: self.addDuration(api.D1)) self.ui.D2.clicked.connect(lambda: self.addDuration(api.D2)) self.ui.D4.clicked.connect(lambda: self.addDuration(api.D4)) self.ui.D8.clicked.connect(lambda: self.addDuration(api.D8)) self.ui.D16.clicked.connect(lambda: self.addDuration(api.D16)) self.ui.D32.clicked.connect(lambda: self.addDuration(api.D32)) self.ui.D64.clicked.connect(lambda: self.addDuration(api.D64)) self.ui.D128.clicked.connect(lambda: self.addDuration(api.D128)) self.ui.DB.clicked.connect(lambda: self.addDuration(api.DB)) self.ui.DL.clicked.connect(lambda: self.addDuration(api.DL)) self.ui.ticks.valueChanged.connect(self.drawLabel) self.exec() #blocks until the dialog gets closed #TODO: better key handling. Esc in the ticks field should not close the dialog but return the keyboard focus to the durations def reset(self): self.ui.ticks.setValue(0) self.clickedSoFar = [] self.ui.durationLabel.setText("") def addDuration(self, duration): self.clickedSoFar.append(duration) nowTicks = self.ui.ticks.value() self.ui.ticks.setValue(nowTicks + duration) def drawLabel(self): #TODO: with nice partitions of real note icons. #Error handling. A too complex or wrong duration (off by one, not equal to a partition etc.) blocks the "OK" button. No, just gives a warning. #backendDurationInstance = api.items.Duration.createByGuessing(self.ui.ticks.value()) #text = backendDurationInstance.lilypond() text = [] for duration, symbol in reversed(sorted(constantsAndConfigs.realNoteDisplay.items())): times = self.clickedSoFar.count(duration) if times: part = str(times) + "x" + symbol text.append(part) self.ui.durationLabel.setText(" + ".join(text)) #There are two types of submenus in this file. The majority is created in menu.py during start up. Like Clef, KeySig etc. These don't need to ask for any dynamic values. #The other is like SecondaryTempoChangeMenu. In menu.py this is bound with a lambda construct so a new instance gets created each time the action is called by the user. Thats why this function has self.__call__ in its init. class Submenu(QtWidgets.QDialog): #TODO: instead of using a QDialog we could use a QWidget and use it as proxy widget on the graphic scene, placing the menu where the input cursor is. def __init__(self, mainWindow, labelString): super().__init__(mainWindow) #if you don't set the parent to the main window the whole screen will be the root and the dialog pops up in the middle of it. #self.setModal(True) #we don't need this when called with self.exec() instead of self.show() self.layout = QtWidgets.QFormLayout() #self.layout = QtWidgets.QVBoxLayout() self.setLayout(self.layout) label = QtWidgets.QLabel(labelString) #"Choose a clef" or so. self.layout.addWidget(label) #self.setFocus(); #self.grabKeyboard(); #redundant for a proper modal dialog. Leave here for documentation reasons. def keyPressEvent(self, event): """Escape closes the dialog by default. We want Enter as "accept value" All other methods of mixing editing, window focus and signals results in strange qt behaviour, triggering the api function twice or more. Especially unitbox.editingFinished is too easy to trigger. The key-event method turned out to be the most straightforward way.""" try: getattr(self, "process") k = event.key() #49=1, 50=2 etc. if k == 0x01000004 or k == 0x01000005: #normal enter or keypad enter event.ignore() self.process() else: #Pressed Esc self.abortHandler() super().keyPressEvent(event) except AttributeError: super().keyPressEvent(event) def showEvent(self, event): #TODO: not optimal but better than nothing. super().showEvent(event) #self.resize(self.layout.geometry().width(), self.layout.geometry().height()) self.resize(self.childrenRect().height(), self.childrenRect().width()) self.updateGeometry() def abortHandler(self): pass def __call__(self): """This instance can be called like a function""" self.exec() #blocks until the dialog gets closed """ Most submenus have the line "lambda, r, value=value"... the r is the return value we get automatically from the Qt buttons which need to be handled. """ class SecondaryClefMenu(Submenu): clefs = [("[1] Treble", lambda: api.insertClef("treble")), ("[2] Bass", lambda: api.insertClef("bass")), ("[3] Alto", lambda: api.insertClef("alto")), ("[4] Drum", lambda: api.insertClef("percussion")), ("[5] Treble ^8 ", lambda: api.insertClef("treble^8")), ("[6] Treble _8 ", lambda: api.insertClef("treble_8")), ("[7] Bass _8 ", lambda: api.insertClef("bass_8")), ] def __init__(self, mainWindow): super().__init__(mainWindow, "choose a clef") for number, (prettyname, function) in enumerate(SecondaryClefMenu.clefs): button = QtWidgets.QPushButton(prettyname) button.setShortcut(QtGui.QKeySequence(str(number+1))) self.layout.addWidget(button) button.clicked.connect(function) button.clicked.connect(self.done) class SecondarySplitMenu(Submenu): splits = [("[2]", lambda: api.split(2)), ("[3]", lambda: api.split(3)), ("[4]", lambda: api.split(4)), ("[5]", lambda: api.split(5)), ("[6]", lambda: api.split(6)), ("[7]", lambda: api.split(7)), ("[8]", lambda: api.split(8)), ("[9]", lambda: api.split(9)), ] def __init__(self, mainWindow): super().__init__(mainWindow, "split chord in") for number, (prettyname, function) in enumerate(SecondarySplitMenu.splits): button = QtWidgets.QPushButton(prettyname) button.setShortcut(QtGui.QKeySequence(str(number+2))) #+1 for enumerate from 0, +2 we start at 2. self.layout.addWidget(button) button.clicked.connect(function) button.clicked.connect(self.done) class SecondaryKeySignatureMenu(Submenu): def __init__(self, mainWindow): super().__init__(mainWindow, "root note is the cursor position") l = [("[{}] {}".format(num+1, modeString.title()), lambda r, modeString=modeString: api.insertCursorCommonKeySignature(modeString)) for num, modeString in enumerate(api.commonKeySignaturesAsList())] for number, (prettyname, function) in enumerate(l): button = QtWidgets.QPushButton(prettyname) button.setShortcut(QtGui.QKeySequence(str(number+1))) self.layout.addWidget(button) button.clicked.connect(function) button.clicked.connect(self.done) class SecondaryDynamicsMenu(Submenu): def __init__(self, mainWindow): super().__init__(mainWindow, "choose a dynamic") button = QtWidgets.QPushButton("[r] Ramp") button.setShortcut(QtGui.QKeySequence("r")) self.layout.addWidget(button) button.clicked.connect(api.insertDynamicRamp) button.clicked.connect(self.done) l = [("[{}] {}".format(num+1, keyword), lambda r, keyword=keyword: api.insertDynamicSignature(keyword)) for num, keyword in enumerate(constantsAndConfigs.dynamics)] for number, (prettyname, function) in enumerate(l): button = QtWidgets.QPushButton(prettyname) button.setShortcut(QtGui.QKeySequence(str(number+1))) self.layout.addWidget(button) button.clicked.connect(function) button.clicked.connect(self.done) class SecondaryMetricalInstructionMenu(Submenu): def __init__(self, mainWindow): super().__init__(mainWindow, "choose a metrical instruction") l = [("[{}] {}".format(num+1, modeString), lambda r, modeString=modeString: api.insertCommonMetricalInstrucions(modeString)) for num, modeString in enumerate(api.commonMetricalInstructionsAsList())] for number, (prettyname, function) in enumerate(l): button = QtWidgets.QPushButton(prettyname) button.setShortcut(QtGui.QKeySequence(str(number+1))) self.layout.addWidget(button) button.clicked.connect(function) button.clicked.connect(self.done) class ChooseOne(Submenu): """A generic submenu that presents a list of options to the users. Only supports up to ten entries, for number shortcuts""" def __init__(self, mainWindow, title:str, lst:Iterable[Tuple[str, Callable]]): if len(lst) > 9: raise ValueError(f"ChooseOne submenu supports up to nine entries. You have {len(lst)}") super().__init__(mainWindow, title) for number, (prettyname, function) in enumerate(lst): button = QtWidgets.QPushButton(f"[{number+1}] {prettyname}") button.setShortcut(QtGui.QKeySequence(str(number+1))) button.setStyleSheet("Text-align:left; padding: 5px;"); self.layout.addWidget(button) button.clicked.connect(function) button.clicked.connect(self.done) class SecondaryTempoChangeMenu(Submenu): """A single tempo change where the user can decide which reference unit and how many of them per minute. Works as "edit tempo point" when there is already a point at this time position. This would be the case anyway thanks to backend-behaviour but the gui has the opportunity to present the current values as a base for editing""" def __init__(self, mainWindow, staticExportTempoItem = None): super().__init__(mainWindow, "choose units per minute, reference note, graph type") self.mainWindow = mainWindow self.staticExportTempoItem = staticExportTempoItem tickindex, unitsPerMinute, referenceTicks, graphType = self.getCurrentValues() #takes self.staticExportTempoItem into account self.unitbox = QtWidgets.QSpinBox() self.unitbox.setMinimum(1) self.unitbox.setMaximum(999) self.unitbox.setValue(unitsPerMinute) self.layout.addWidget(self.unitbox) self.referenceList = QtWidgets.QComboBox() self.referenceList.addItems(constantsAndConfigs.prettyExtendedRhythmsStrings) self.referenceList.setCurrentIndex(constantsAndConfigs.prettyExtendedRhythmsValues.index(referenceTicks)) self.layout.addWidget(self.referenceList) self.interpolationList = QtWidgets.QComboBox() l = api.getListOfGraphInterpolationTypesAsStrings() self.interpolationList.addItems(l) self.interpolationList.setCurrentIndex(l.index(graphType)) self.layout.addWidget(self.interpolationList) self.__call__() def process(self): """It says 'insert' but the backend is a dict. Changes are simply made by overwriting the whole thing and the backend sends new data to draw to the GUI""" tickindex, unitsPerMinute, referenceTicks, graphType = self.getCurrentValues() newReferenceTicks = constantsAndConfigs.prettyExtendedRhythmsValues[self.referenceList.currentIndex()] graphType = api.getListOfGraphInterpolationTypesAsStrings()[self.interpolationList.currentIndex()] api.insertTempoItemAtAbsolutePosition(tickindex, self.unitbox.value(), newReferenceTicks, graphType) self.done(True) def getCurrentValues(self): """Get the current values from the note-editing backend cursor""" if self.staticExportTempoItem: return self.staticExportTempoItem["position"], self.staticExportTempoItem["unitsPerMinute"], self.staticExportTempoItem["referenceTicks"], self.staticExportTempoItem["graphType"], else: assert self.mainWindow.scoreView.scoreScene.cursor.cursorExportObject return self.mainWindow.scoreView.scoreScene.cursor.cursorExportObject["tickindex"], self.mainWindow.scoreView.scoreScene.cursor.cursorExportObject["tempoUnitsPerMinute"], self.mainWindow.scoreView.scoreScene.cursor.cursorExportObject["tempoReferenceTicks"], self.mainWindow.scoreView.scoreScene.cursor.cursorExportObject["tempoGraphType"], class SecondaryTemporaryTempoChangeMenu(Submenu): """Essentially: What kind of fermata effect do you want?""" lastCustomValue = 0.42 def __init__(self, mainWindow): super().__init__(mainWindow, "[enter] to use value") self.spinbox = QtWidgets.QDoubleSpinBox() self.spinbox.setValue(SecondaryTemporaryTempoChangeMenu.lastCustomValue) self.spinbox.setDecimals(2) self.spinbox.setMinimum(0.01) self.spinbox.setSingleStep(0.01) self.layout.addWidget(self.spinbox) def process(self): v = round(self.spinbox.value(), 2) SecondaryTemporaryTempoChangeMenu.lastCustomValue = v api.insertTempoChangeDuringDuration(v) self.done(True) class BlockPropertiesEdit(Submenu): def __init__(self, mainWindow, staticExportItem): super().__init__(mainWindow, "") self.mainWindow = mainWindow self.staticExportItem = staticExportItem self.layout.insertRow(0, QtWidgets.QLabel("edit block #{}".format(staticExportItem["id"]))) self.name = QtWidgets.QLineEdit(self.staticExportItem["name"]) self.name.selectAll() self.layout.addRow("name", self.name) #self.minimumInTicks = QtWidgets.QSpinBox() self.minimumInTicks = CombinedTickWidget() self.minimumInTicks.setValue(self.staticExportItem["minimumInTicks"]) self.layout.addRow("minimum in ticks", self.minimumInTicks) self.__call__() def process(self): newParametersDict = { "minimumInTicks":self.minimumInTicks.value(), "name":self.name.text(), } api.changeBlock(self.staticExportItem["id"], newParametersDict) self.done(True) class TempoBlockPropertiesEdit(Submenu): def __init__(self, mainWindow, staticExportItem): super().__init__(mainWindow, "") self.mainWindow = mainWindow self.staticExportItem = staticExportItem self.layout.insertRow(0, QtWidgets.QLabel("edit block #{}".format(staticExportItem["id"]))) self.name = QtWidgets.QLineEdit(self.staticExportItem["name"]) self.name.selectAll() self.layout.addRow("name", self.name) self.duration = CombinedTickWidget() self.duration.setValue(self.staticExportItem["duration"]) self.layout.addRow("duration in ticks", self.duration) self.__call__() def process(self): newParametersDict = { "duration":self.duration.value(), "name":self.name.text(), } api.changeTempoBlock(self.staticExportItem["id"], newParametersDict) self.done(True) class GridRhytmEdit(Submenu): def __init__(self, mainWindow): super().__init__(mainWindow, "") self.mainWindow = mainWindow self.layout.insertRow(0, QtWidgets.QLabel("Edit Grid")) self.duration = CombinedTickWidget() self.duration.setValue(constantsAndConfigs.gridRhythm) self.layout.addRow("duration in ticks", self.duration) self.opacity = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.opacity.setMinimum(0) self.opacity.setMaximum(50) self.opacityLabel = QtWidgets.QLabel("opacity: {}%".format(int(constantsAndConfigs.gridOpacity * 100))) self.layout.addRow(self.opacityLabel, self.opacity) self.opacity.valueChanged.connect(lambda: self.opacityLabel.setText("opacity: {}%".format(self.opacity.value()))) self.opacity.setValue(int(constantsAndConfigs.gridOpacity * 100)) self.opacity.valueChanged.connect(lambda: self.mainWindow.scoreView.scoreScene.grid.setOpacity(self.opacity.value() / 100)) #only react to changes after the initial value was set. self.__call__() def process(self): constantsAndConfigs.gridRhythm = self.duration.value() constantsAndConfigs.gridOpacity = self.opacity.value() / 100 api.session.guiSharedDataToSave["grid_opacity"] = constantsAndConfigs.gridOpacity api.session.guiSharedDataToSave["grid_rhythm"] = constantsAndConfigs.gridRhythm self.mainWindow.scoreView.scoreScene.grid.redrawTickGrid() #opacity was already set live, but finally it will be used here again. self.done(True) def abortHandler(self): self.mainWindow.scoreView.scoreScene.grid.setOpacity(constantsAndConfigs.gridOpacity) #reset to initial value and undo the live preview class TransposeMenu(Submenu): def __init__(self, mainWindow, what): super().__init__(mainWindow, "Transpose {}".format(what.title())) assert what in ("item", "score") self.what = what self.layout.insertRow(0, QtWidgets.QLabel("Construct Interval from relative distance")) self.fromNote = QtWidgets.QComboBox() self.fromNote.addItems(pitch.sortedNoteNameList) self.fromNote.setCurrentIndex(pitch.sortedNoteNameList.index("c'")) self.layout.addRow("from", self.fromNote) self.to = QtWidgets.QComboBox() self.to.addItems(pitch.sortedNoteNameList) self.to.setCurrentIndex(pitch.sortedNoteNameList.index("c'")) self.layout.addRow("to", self.to) self.__call__() def process(self): fromPitch = pitch.ly2pitch[self.fromNote.currentText()] toPitch = pitch.ly2pitch[self.to.currentText()] if self.what == "item": api.transpose(fromPitch, toPitch) #item on cursor position elif self.what == "score": api.transposeScore(fromPitch, toPitch) self.done(True) class SecondaryProperties(Submenu): def __init__(self, mainWindow): """Directly edits the backend score meta data. There is no api and no callbacks""" super().__init__(mainWindow, "Meta Data") dictionary = api.getMetadata() test = set(type(key) for key in dictionary.keys()) assert len(test) == 1 assert list(test)[0] == str self.widgets = {key:self.makeValueWidget(value) for key, value in dictionary.items()} importantKeys = ("title", "composer", "instrument", "copyright") #Draw important metadata widgets first for k in importantKeys: self.layout.addRow(k.title(), self.widgets[k]) self.layout.addRow(QHLine()) #Then the rest in alphabetical order for key, widget in sorted(self.widgets.items()): if not key in importantKeys: self.layout.addRow(key.title(), widget) self.__call__() def makeValueWidget(self, value): types = { str : QtWidgets.QLineEdit, int : QtWidgets.QSpinBox, float : QtWidgets.QDoubleSpinBox, } typ = type(value) widget = types[typ]() if typ == str: widget.setText(value) elif typ == int or typ == float: widget.setValue(value) return widget def getValueFromWidget(self, widget): typ = type(widget) if typ == QtWidgets.QLineEdit: return widget.text() elif typ == QtWidgets.QSpinBox or typ == QtWidgets.QDoubleSpinBox: return widget.value() def process(self): api.setMetadata({key:self.getValueFromWidget(widget) for key, widget in self.widgets.items()}) self.done(True) #Instance gets killed afterwards. No need to save the new values. class SecondaryProgramChangeMenu(Submenu): lastProgramValue = 0 lastMsbValue = 0 lastLsbValue = 0 def __init__(self, mainWindow): super().__init__(mainWindow, "Instrument Change") self.program = QtWidgets.QSpinBox() self.program.setValue(type(self).lastProgramValue) self.msb = QtWidgets.QSpinBox() self.msb.setValue(type(self).lastMsbValue) self.lsb = QtWidgets.QSpinBox() self.lsb.setValue(type(self).lastLsbValue) self.shortInstrumentName = QtWidgets.QLineEdit() for label, spinbox in (("Program", self.program), ("Bank MSB", self.msb), ("Bank LSB", self.lsb)): spinbox.setMinimum(0) spinbox.setMaximum(127) spinbox.setSingleStep(1) self.layout.addRow(label, spinbox) self.layout.addRow("Short Name", self.shortInstrumentName) self.insert = QtWidgets.QPushButton("Insert") self.insert.clicked.connect(self.process) self.layout.addWidget(self.insert) def process(self): program = self.program.value() type(self).lastProgramValue = program msb = self.msb.value() type(self).lastMsbValue = msb lsb = self.lsb.value() type(self).lastLsbValue = lsb api.instrumentChange(program, msb, lsb, self.shortInstrumentName.text(), ) self.done(True) class SecondaryChannelChangeMenu(Submenu): lastCustomValue = 0 def __init__(self, mainWindow): super().__init__(mainWindow, "Channel Change 1-16. [enter] to use value") self.spinbox = QtWidgets.QSpinBox() self.spinbox.setValue(type(self).lastCustomValue) self.spinbox.setMinimum(1) self.spinbox.setMaximum(16) self.spinbox.setSingleStep(1) self.layout.addRow("Channel", self.spinbox) self.name = QtWidgets.QLineEdit() self.layout.addRow("Text", self.name) def process(self): v = self.spinbox.value() type(self).lastCustomValue = v api.channelChange(v-1, self.name.text()) self.done(True) #Normal Functions ############ def pedalNoteChooser(mainWindow): try: constantsAndConfigs.realNotesStrings[constantsAndConfigs.realNotesValues.index(constantsAndConfigs.gridRhythm)+1] rhythmString = QtWidgets.QInputDialog.getItem(mainWindow, "Insert Pedal Notes", "Use duration as base", constantsAndConfigs.realNotesStrings, constantsAndConfigs.realNotesValues.index(constantsAndConfigs.gridRhythm)+1, False) except IndexError: rhythmString = QtWidgets.QInputDialog.getItem(mainWindow, "Insert Pedal Notes", "Use duration as base", constantsAndConfigs.realNotesStrings, constantsAndConfigs.realNotesValues.index(constantsAndConfigs.gridRhythm), False) if rhythmString[1]: #bool. Canceled? for baseDuration, v in constantsAndConfigs.commonNotes: if v == rhythmString[0]: api.pedalNotes(baseDuration) def forwardText(mainWindow, title, function): text, status = QtWidgets.QInputDialog.getText(mainWindow, title, title) if status: function(text)