#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2020, 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 . """ 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}") #from PyQt5 import QtOpenGL #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 .helper import setPaletteAndFont 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 * #Construct QAppliction before constantsAndCOnfigs, which has the fontDB QtGui.QGuiApplication.setDesktopSettingsAware(False) #We need our own font so the user interface stays predictable QtGui.QGuiApplication.setDesktopFileName(PATHS["desktopfile"]) #qtApp imported from template.engine.start. Since Qt 5.15 or PyQt 5.15 you really can't have only one QApplication during program lifetime, even if you try to quit and del the first one. from qtgui.constantsAndConfigs import constantsAndConfigs class EventLoop(object): def __init__(self): """The loop for all things GUI and controlling the GUI (e.g. by a control midi in port) By default use fastConnect. 0 ms means "if there is time". 10ms-20ms is smooth. 100ms is still ok. Influences everything. Control Midi In Latency, playback cursor scrolling smoothnes etc. But not realtime. This is not the realtime loop. Converting midi into instrument sounds or playing back sequenced midi data is not handled by this loop at all. Creating a non-qt class for the loop is an abstraction layer that enables the engine to work without modification for non-gui situations. In this case it will use its own loop, like python async etc. A qt event loop needs the qt-app started. Otherwise it will not run. We init the event loop outside of main but call start from the mainWindow. """ self.fastLoop = QtCore.QTimer() self.slowLoop = QtCore.QTimer() def fastConnect(self, function): self.fastLoop.timeout.connect(function) def slowConnect(self, function): self.slowLoop.timeout.connect(function) def fastDisconnect(self, function): """The function must be the exact instance that was registered""" self.fastLoop.timeout.disconnect(function) def slowDisconnect(self, function): """The function must be the exact instance that was registered""" self.slowLoop.timeout.disconnect(function) def start(self): """The event loop MUST be started after the Qt Application instance creation""" logger.info("Starting fast qt event loop") self.fastLoop.start(20) logger.info("Starting slow qt event loop") self.slowLoop.start(100) def stop(self): logger.info("Stopping fast qt event loop") self.fastLoop.stop() logger.info("Stopping slow qt event loop") self.slowLoop.stop() 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 = QtCore.QLocale().languageToString(QtCore.QLocale().language()) logger.info("{}: Language set to {}".format(METADATA["name"], language)) if language in METADATA["supportedLanguages"]: templateTranslator = QtCore.QTranslator() templateTranslator.load(METADATA["supportedLanguages"][language], ":/template/translations/") #colon to make it a resource URL qtApp.installTranslator(templateTranslator) otherTranslator = QtCore.QTranslator() otherTranslator.load(METADATA["supportedLanguages"][language], ":translations") #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 #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.xFactor = 1 #keep track of the x stretch factor. self.setWindowTitle(self.nsmClient.ourClientNameUnderNSM) self.qtApp.setApplicationDisplayName(self.nsmClient.ourClientNameUnderNSM) 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) 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.initiGuiSharedDataToSave() def start(self): api.session.eventLoop.start() #The event loop must be started after the qt app api.session.eventLoop.fastConnect(self.nsmClient.reactToMessage) 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"]) 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": #TODO: this is a hack until we figure out how to cleanly handle hide vs quite from outside the application self.hideGUI() #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)