Sampled Instrument Player with static and monolithic design. All instruments are built-in.
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.
 
 

388 lines
17 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
This application 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
import os
import os.path
from sys import argv as sysargv
from sys import exit as sysexit
#Third Party
from PyQt5 import QtCore, QtGui, QtWidgets
logger.info(f"PyQt Version: {QtCore.PYQT_VERSION_STR}")
#Template Modules
from .nsmclient import NSMClient
from .usermanual import UserManual
from .debugScript import DebugScriptRunner
from .menu import Menu
from .resources import *
from .about import About
from .changelog import Changelog
from .helper import setPaletteAndFont
from .eventloop import EventLoop
from template.start import PATHS, qtApp
#Client modules
from engine.config import * #imports METADATA
import engine.api as api #This loads the engine and starts a session.
from qtgui.designer.mainwindow import Ui_MainWindow #The MainWindow designer file is loaded from the CLIENT side
from qtgui.resources import *
from qtgui.constantsAndConfigs import constantsAndConfigs
api.session.eventLoop = EventLoop()
#QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_DontUseNativeMenuBar) #Force a real menu bar. Qt on wayland will not display it otherwise.
#Setup the translator before classes are set up. Otherwise we can't use non-template translation.
#to test use LANGUAGE=de_DE.UTF-8 . not LANG=
#Language variants like de_AT.UTF-8 will be detected automatically and will result in Qt language detection as "German"
language = QtCore.QLocale().languageToString(QtCore.QLocale().language())
logger.info("{}: Language set to {}".format(METADATA["name"], language))
if language in METADATA["supportedLanguages"]: # type: ignore
templateTranslator = QtCore.QTranslator()
templateTranslator.load(METADATA["supportedLanguages"][language], ":/template/translations/") # type: ignore #colon to make it a resource URL
qtApp.installTranslator(templateTranslator)
otherTranslator = QtCore.QTranslator()
otherTranslator.load(METADATA["supportedLanguages"][language], ":translations") # type: ignore #colon to make it a resource URL
qtApp.installTranslator(otherTranslator)
else:
"""silently fall back to English by doing nothing"""
class MainWindow(QtWidgets.QMainWindow):
"""Before the mainwindow class is even parsed all the engine imports are done.
As side effects they set up our session, callbacks and api. They are now waiting
for the start signal which will be send by NSM. Session Management simulates user actions,
so their place is in the (G)UI. Therefore the MainWindows role is to set up the nsm client."""
def __init__(self):
super().__init__()
self.qtApp = qtApp
self.qtApp.setWindowIcon(QtGui.QIcon(":icon.png")) #non-template part of the program
QtGui.QIcon.setThemeName("hicolor") #audio applications can be found here. We have no need for other icons.
logger.info("Init MainWindow")
#Callbacks. Must be registered before startEngine.
api.callbacks.message.append(self.callback_message)
#NSM Client
self.nsmClient = NSMClient(prettyName = METADATA["name"], #will raise an error and exit if this is not run from NSM
supportsSaveStatus = True,
saveCallback = api.session.nsm_saveCallback,
openOrNewCallback = api.session.nsm_openOrNewCallback,
exitProgramCallback = self._nsmQuit,
hideGUICallback = self.hideGUI,
showGUICallback = self.showGUI,
loggingLevel = logging.getLogger().level,
)
#Set up the user interface from Designer and other widgets
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.fPalBlue = setPaletteAndFont(self.qtApp)
self.userManual = UserManual(mainWindow=self) #starts hidden. menu shows, closeEvent hides.
self.changelog = Changelog(mainWindow=self) #starts hidden. menu shows, closeEvent hides.
self.xFactor = 1 #keep track of the x stretch factor.
self.setWindowTitle(self.nsmClient.ourClientNameUnderNSM)
self.qtApp.setApplicationName(self.nsmClient.ourClientNameUnderNSM)
self.qtApp.setApplicationDisplayName(self.nsmClient.ourClientNameUnderNSM)
self.qtApp.setOrganizationName("Laborejo Software Suite")
self.qtApp.setOrganizationDomain("laborejo.org")
self.qtApp.setApplicationVersion(METADATA["version"])
self.setAcceptDrops(True)
self.debugScriptRunner = DebugScriptRunner(apilocals=locals()) #needs to have trueInit called after the session and nsm was set up. Which happens in startEngine.
self.debugScriptRunner.trueInit(nsmClient=self.nsmClient)
#Show the About Dialog the first time the program starts up.
#This is the initial state user/system wide and not a saved in NSM nor bound to the NSM ID (like window position)
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if not settings.contains("showAboutDialog"):
settings.setValue("showAboutDialog", METADATA["showAboutDialogFirstStart"])
self.about = About(mainWindow=self) #This does not show, it only creates. Showing is decided in self.start
self.ui.menubar.setNativeMenuBar(False) #Force a real menu bar. Qt on wayland will not display it otherwise.
self.menu = Menu(mainWindow=self) #needs the about dialog, save file and the api.session ready.
self.installEventFilter(self) #Bottom of the file. Works around the hover numpad bug that is in Qt for years.
self.initiGuiSharedDataToSave()
def start(self, additionalData:dict=None):
api.session.eventLoop.start() #The event loop must be started after the qt app
api.session.eventLoop.fastConnect(self.nsmClient.reactToMessage)
if additionalData: #e.g. tembro global sample directory
api.startEngine(self.nsmClient, additionalData) #Load the file, start the eventLoop. Triggers all the callbacks that makes us draw.
else:
api.startEngine(self.nsmClient) #Load the file, start the eventLoop. Triggers all the callbacks that makes us draw.
if api.session.guiWasSavedAsNSMVisible:
self.showGUI()
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) # type: ignore #mypy cannot handle METADATA
if settings.contains("showAboutDialog") and settings.value("showAboutDialog", type=bool):
QtCore.QTimer.singleShot(100, self.about.show) #Qt Event loop is not ready at that point. We need to wait for the paint event. This is not to stall for time: Using the event loop guarantees that it exists
elif not self.nsmClient.sessionName == "NOT-A-SESSION": #standalone mode
self.ui.actionQuit.setShortcut("")
#TODO: this is a hack until we figure out how to cleanly handle hide vs quite from outside the application
self.hideGUI()
self._zoom() #enable zoom factor loaded from save file
#def event(self, event):
# print (event.type())
# return super().event(event)
def callback_message(self, title, text):
QtWidgets.QMessageBox.warning(self, title ,text)
def dragEnterEvent(self, event):
"""Needs self.setAcceptDrops(True) in init"""
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
def dropEvent(self, event):
"""Needs self.setAcceptDrops(True) in init.
Having that function in the mainWindow will not make drops available for subwindows
like About or UserManual. """
for url in event.mimeData().urls():
filePath = url.toLocalFile()
#Decide here if you want only files, only directories, both etc.
if os.path.isfile(filePath) and filePath.lower().endswith(".sf2"):
#linkedPath = self.nsmClient.importResource(filePath)
print ("received drop")
def storeWindowSettings(self):
"""Window state is not saved in the real save file. That would lead to portability problems
between computers, like different screens and resolutions.
For convenience that means we just use the damned qt settings and save wherever qt wants.
We don't use the NSM id, session share their placement.
bottom line: get a tiling window manager.
"""
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
settings.setValue("geometry", self.saveGeometry())
settings.setValue("windowState", self.saveState())
def restoreWindowSettings(self):
"""opposite of storeWindowSettings. Read there."""
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if settings.contains("geometry") and settings.contains("windowState"):
self.restoreGeometry(settings.value("geometry"))
self.restoreState(settings.value("windowState"))
def initiGuiSharedDataToSave(self):
"""Called by init"""
if not "last_export_dir" in api.session.guiSharedDataToSave:
api.session.guiSharedDataToSave["last_export_dir"] = os.path.expanduser("~")
if "grid_opacity" in api.session.guiSharedDataToSave:
constantsAndConfigs.gridOpacity = float(api.session.guiSharedDataToSave["grid_opacity"])
if "grid_rhythm" in api.session.guiSharedDataToSave:
#setting this is enough. When the grid gets created it fetches the constantsAndConfigs value.
#Set only in submenus.GridRhytmEdit
constantsAndConfigs.gridRhythm = int(api.session.guiSharedDataToSave["grid_rhythm"])
#Stretch
if "ticks_to_pixel_ratio" in api.session.guiSharedDataToSave:
#setting this is enough. Drawing on startup uses the constantsAndConfigs value.
#Set only in ScoreView._stretchXCoordinates
constantsAndConfigs.ticksToPixelRatio = float(api.session.guiSharedDataToSave["ticks_to_pixel_ratio"])
if "zoom_factor" in api.session.guiSharedDataToSave:
#setting this is enough. Drawing on startup uses the constantsAndConfigs value.
#Set only in ScoreView._zoom
constantsAndConfigs.zoomFactor = float(api.session.guiSharedDataToSave["zoom_factor"])
#Zoom and Stretch
def wheelEvent(self, ev):
modifiers = QtWidgets.QApplication.keyboardModifiers()
if modifiers == QtCore.Qt.ControlModifier:
ev.accept()
if ev.angleDelta().y() > 0:
self.zoomIn()
else:
self.zoomOut()
elif modifiers == QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier:
ev.accept()
if ev.angleDelta().y() > 0:
self.widen()
else:
self.shrinken()
else:
super().wheelEvent(ev) #send to the items
def _zoom(self):
api.session.guiSharedDataToSave["zoom_factor"] = constantsAndConfigs.zoomFactor
#Send to client mainWindow
self.zoom(constantsAndConfigs.zoomFactor)
def zoom(self, scaleFactor:float):
raise NotImplementedError("Reimplement this in your program. Can do nothing, if you want. See template/qtgui/mainwindow.py for example implementation")
#self.scoreView.resetTransform()
#self.scoreView.scale(scaleFactor, scaleFactor)
def stretchXCoordinates(self, factor):
raise NotImplementedError("Reimplement this in your program. Can do nothing, if you want. See template/qtgui/mainwindow.py for example implementation")
#self.scoreView.stretchXCoordinates(factor)
#self.scoreView.centerOnCursor()
def zoomIn(self):
constantsAndConfigs.zoomFactor = round(constantsAndConfigs.zoomFactor + 0.25, 2)
if constantsAndConfigs.zoomFactor > 2.5:
constantsAndConfigs.zoomFactor = 2.5
self._zoom()
return True
def zoomOut(self):
constantsAndConfigs.zoomFactor = round(constantsAndConfigs.zoomFactor - 0.25, 2)
if constantsAndConfigs.zoomFactor < constantsAndConfigs.maximumZoomOut:
constantsAndConfigs.zoomFactor = constantsAndConfigs.maximumZoomOut
self._zoom()
return True
def zoomNull(self):
constantsAndConfigs.zoomFactor = 1
self._zoom()
def _stretchXCoordinates(self, factor):
"""Reposition the items on the X axis.
Call goes through all parents/children, starting from here.
The parent sets the X coordinates of its children.
Then the parent calls the childs _stretchXCoordinates() method if the child has children
itself. For example a rectangleItem has a position which is set by the parent. But the
rectangleItem has a right border which needs to be adjusted as well. This right border is
treated as child of the rectItem, handled by rectItem._stretchXCoordinates(factor).
"""
if self.xFactor * factor < 0.015:
return
self.xFactor *= factor
constantsAndConfigs.ticksToPixelRatio /= factor
api.session.guiSharedDataToSave["ticks_to_pixel_ratio"] = constantsAndConfigs.ticksToPixelRatio
self.stretchXCoordinates(factor)
return True
def widen(self):
#self._stretchXCoordinates(1*1.2) #leads to rounding errors
self._stretchXCoordinates(2)
def shrinken(self):
#self._stretchXCoordinates(1/1.2) #leads to rounding errors
self._stretchXCoordinates(0.5)
#Close and exit
def _nsmQuit(self, ourPath, sessionName, ourClientNameUnderNSM):
logger.info("Qt main window received NSM exit callback. Calling pythons system exit. ")
self.storeWindowSettings()
#api.stopEngine() #will be called trough sessions atexit
#self.qtApp.quit() #does not work. This will fail and pynsmclient2 will send SIGKILL
sysexit() #works, NSM cleanly detects a quit. Triggers the session atexit condition
logger.error("Code executed after sysexit. This message should not have been visible.")
#Code here never gets executed.
def closeEvent(self, event):
"""This is the manual close event, not the NSM Message.
Ignore. We use it to send the GUI into hiding."""
event.ignore()
self.hideGUI()
def hideGUI(self):
self.storeWindowSettings()
self.hide()
self.nsmClient.announceGuiVisibility(False)
def showGUI(self):
self.restoreWindowSettings()
self.show()
self.nsmClient.announceGuiVisibility(True)
def eventFilter(self, obj, event):
"""Qt has a known but unresolved bug (for many years now)
that shortcuts with the numpad don't trigger. This has little
chance of ever getting fixed, despite getting reported multiple
times.
Try to uninstall this filter (self.__init__) after a new qt release
and check if it works without. It should (it did already in the past)
but so far no luck...
What do we do?
We intercept every key and see if it was with a numpad modifier.
If yes we trigger the menu action ourselves and discard the event
We also need to separate the action of assigning a hover shortcut
to a menu and actually triggering it. Since we are in the template
part of the program we can't assume there is a main widget,
as in Patroneo and Laborejos case.
The obj is always MainWindow, so we can't detect if the menu was open or not.
self.qtApp.focusWidget() is also NOT the menu.
The menu is only triggered AFTER the filter. But we need to
detect if a menu is open while we are running the filter.
self.menu.ui.menubar.activeAction()
For each key press/release we receive 3 keypress events, not
counting autoRepeats. event.type() which is different from type(event)
returns 51 (press), 6(release), 7(accept) for the three. This is key press, release and event accept(?).
This event filter somehow does not differentiate between numpad
on or numpad off. There maybe is another qt check for that.
"""
if (not self.menu.ui.menubar.activeAction()) and event.type() == QtCore.QEvent.ShortcutOverride and type(event) is QtGui.QKeyEvent and event.modifiers() == QtCore.Qt.KeypadModifier and event.text() and event.text() in "0123456789":
action = self.menu.hoverShortcutDict[event.text()]
if action:
action.trigger()
event.accept() #despite what Qt docs say it is not enough to return True to "eat" the event.
return True
else:
#No keypad shortcut. Just use the normal handling.
return super().eventFilter(obj, event)