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
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() == 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)
|
|
|