#! /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 )