#! /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 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 .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"]: 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 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.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): 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": #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() == 51 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)