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.

382 lines
16 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
Copyright 2020, Nils Hilbricht, Germany ( )
This file is part of the Laborejo Software Suite ( ),
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
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__);"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"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
#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):
def slowConnect(self, function):
def fastDisconnect(self, function):
"""The function must be the exact instance that was registered"""
def slowDisconnect(self, function):
"""The function must be the exact instance that was registered"""
def start(self):
"""The event loop MUST be started after the Qt Application instance creation""""Starting fast qt event loop")
self.fastLoop.start(20)"Starting slow qt event loop")
def stop(self):"Stopping fast qt event loop")
self.fastLoop.stop()"Stopping slow qt event loop")
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())"{}: 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
otherTranslator = QtCore.QTranslator()
otherTranslator.load(METADATA["supportedLanguages"][language], ":translations") #colon to make it a resource URL
"""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):
self.qtApp = qtApp
self.qtApp.setWindowIcon(QtGui.QIcon(":icon.png")) #non-template part of the program
#Callbacks. Must be registered before startEngine.
#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.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.debugScriptRunner = DebugScriptRunner(apilocals=locals()) #needs to have trueInit called after the session and nsm was set up. Which happens in startEngine.
#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. = Menu(mainWindow=self) #needs the about dialog, save file and the api.session ready.
def start(self):
api.session.eventLoop.start() #The event loop must be started after the qt app
api.startEngine(self.nsmClient) #Load the file, start the eventLoop. Triggers all the callbacks that makes us draw.
if api.session.guiWasSavedAsNSMVisible:
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if settings.contains("showAboutDialog") and settings.value("showAboutDialog", type=bool):
QtCore.QTimer.singleShot(100, #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
#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():
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"):
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"])
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:
if ev.angleDelta().y() > 0:
elif modifiers == QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier:
if ev.angleDelta().y() > 0:
super().wheelEvent(ev) #send to the items
def _zoom(self):
api.session.guiSharedDataToSave["zoom_factor"] = constantsAndConfigs.zoomFactor
#Send to client mainWindow
def zoom(self, scaleFactor:float):
raise NotImplementedError("Reimplement this in your program. Can do nothing, if you want. See template/qtgui/ for example implementation")
#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/ for example implementation")
def zoomIn(self):
constantsAndConfigs.zoomFactor = round(constantsAndConfigs.zoomFactor + 0.25, 2)
if constantsAndConfigs.zoomFactor > 2.5:
constantsAndConfigs.zoomFactor = 2.5
return True
def zoomOut(self):
constantsAndConfigs.zoomFactor = round(constantsAndConfigs.zoomFactor - 0.25, 2)
if constantsAndConfigs.zoomFactor < constantsAndConfigs.maximumZoomOut:
constantsAndConfigs.zoomFactor = constantsAndConfigs.maximumZoomOut
return True
def zoomNull(self):
constantsAndConfigs.zoomFactor = 1
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:
self.xFactor *= factor
constantsAndConfigs.ticksToPixelRatio /= factor
api.session.guiSharedDataToSave["ticks_to_pixel_ratio"] = constantsAndConfigs.ticksToPixelRatio
return True
def widen(self):
#self._stretchXCoordinates(1*1.2) #leads to rounding errors
def shrinken(self):
#self._stretchXCoordinates(1/1.2) #leads to rounding errors
#Close and exit
def _nsmQuit(self, ourPath, sessionName, ourClientNameUnderNSM):"Qt main window received NSM exit callback. Calling pythons system exit. ")
#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."""
def hideGUI(self):
def showGUI(self):