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.
384 lines
16 KiB
384 lines
16 KiB
#! /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 <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}")
|
|
#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
|
|
|
|
#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 = QtWidgets.QApplication(sysargv)
|
|
|
|
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", linkedPath)
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|