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
17KB

  1. #! /usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Copyright 2021, Nils Hilbricht, Germany ( https://www.hilbricht.net )
  5. This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
  6. This application is free software: you can redistribute it and/or modify
  7. it under the terms of the GNU General Public License as published by
  8. the Free Software Foundation, either version 3 of the License, or
  9. (at your option) any later version.
  10. This program is distributed in the hope that it will be useful,
  11. but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. GNU General Public License for more details.
  14. You should have received a copy of the GNU General Public License
  15. along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. """
  17. import logging; logger = logging.getLogger(__name__); logger.info("import")
  18. #Standard Library
  19. import os
  20. import os.path
  21. from sys import argv as sysargv
  22. from sys import exit as sysexit
  23. #Third Party
  24. from PyQt5 import QtCore, QtGui, QtWidgets
  25. logger.info(f"PyQt Version: {QtCore.PYQT_VERSION_STR}")
  26. #from PyQt5 import QtOpenGL
  27. #Template Modules
  28. from .nsmclient import NSMClient
  29. from .usermanual import UserManual
  30. from .debugScript import DebugScriptRunner
  31. from .menu import Menu
  32. from .resources import *
  33. from .about import About
  34. from .helper import setPaletteAndFont
  35. from .eventloop import EventLoop
  36. from template.start import PATHS, qtApp
  37. #Client modules
  38. from engine.config import * #imports METADATA
  39. import engine.api as api #This loads the engine and starts a session.
  40. from qtgui.designer.mainwindow import Ui_MainWindow #The MainWindow designer file is loaded from the CLIENT side
  41. from qtgui.resources import *
  42. from qtgui.constantsAndConfigs import constantsAndConfigs
  43. api.session.eventLoop = EventLoop()
  44. #QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_DontUseNativeMenuBar) #Force a real menu bar. Qt on wayland will not display it otherwise.
  45. #Setup the translator before classes are set up. Otherwise we can't use non-template translation.
  46. #to test use LANGUAGE=de_DE.UTF-8 . not LANG=
  47. #Language variants like de_AT.UTF-8 will be detected automatically and will result in Qt language detection as "German"
  48. language = QtCore.QLocale().languageToString(QtCore.QLocale().language())
  49. logger.info("{}: Language set to {}".format(METADATA["name"], language))
  50. if language in METADATA["supportedLanguages"]:
  51. templateTranslator = QtCore.QTranslator()
  52. templateTranslator.load(METADATA["supportedLanguages"][language], ":/template/translations/") #colon to make it a resource URL
  53. qtApp.installTranslator(templateTranslator)
  54. otherTranslator = QtCore.QTranslator()
  55. otherTranslator.load(METADATA["supportedLanguages"][language], ":translations") #colon to make it a resource URL
  56. qtApp.installTranslator(otherTranslator)
  57. else:
  58. """silently fall back to English by doing nothing"""
  59. class MainWindow(QtWidgets.QMainWindow):
  60. """Before the mainwindow class is even parsed all the engine imports are done.
  61. As side effects they set up our session, callbacks and api. They are now waiting
  62. for the start signal which will be send by NSM. Session Management simulates user actions,
  63. so their place is in the (G)UI. Therefore the MainWindows role is to set up the nsm client."""
  64. def __init__(self):
  65. super().__init__()
  66. self.qtApp = qtApp
  67. self.qtApp.setWindowIcon(QtGui.QIcon(":icon.png")) #non-template part of the program
  68. QtGui.QIcon.setThemeName("hicolor") #audio applications can be found here. We have no need for other icons.
  69. logger.info("Init MainWindow")
  70. #Callbacks. Must be registered before startEngine.
  71. api.callbacks.message.append(self.callback_message)
  72. #NSM Client
  73. self.nsmClient = NSMClient(prettyName = METADATA["name"], #will raise an error and exit if this is not run from NSM
  74. supportsSaveStatus = True,
  75. saveCallback = api.session.nsm_saveCallback,
  76. openOrNewCallback = api.session.nsm_openOrNewCallback,
  77. exitProgramCallback = self._nsmQuit,
  78. hideGUICallback = self.hideGUI,
  79. showGUICallback = self.showGUI,
  80. loggingLevel = logging.getLogger().level,
  81. )
  82. #Set up the user interface from Designer and other widgets
  83. self.ui = Ui_MainWindow()
  84. self.ui.setupUi(self)
  85. self.fPalBlue = setPaletteAndFont(self.qtApp)
  86. self.userManual = UserManual(mainWindow=self) #starts hidden. menu shows, closeEvent hides.
  87. self.xFactor = 1 #keep track of the x stretch factor.
  88. self.setWindowTitle(self.nsmClient.ourClientNameUnderNSM)
  89. self.qtApp.setApplicationName(self.nsmClient.ourClientNameUnderNSM)
  90. self.qtApp.setApplicationDisplayName(self.nsmClient.ourClientNameUnderNSM)
  91. self.qtApp.setOrganizationName("Laborejo Software Suite")
  92. self.qtApp.setOrganizationDomain("laborejo.org")
  93. self.qtApp.setApplicationVersion(METADATA["version"])
  94. self.setAcceptDrops(True)
  95. self.debugScriptRunner = DebugScriptRunner(apilocals=locals()) #needs to have trueInit called after the session and nsm was set up. Which happens in startEngine.
  96. self.debugScriptRunner.trueInit(nsmClient=self.nsmClient)
  97. #Show the About Dialog the first time the program starts up.
  98. #This is the initial state user/system wide and not a saved in NSM nor bound to the NSM ID (like window position)
  99. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  100. if not settings.contains("showAboutDialog"):
  101. settings.setValue("showAboutDialog", METADATA["showAboutDialogFirstStart"])
  102. self.about = About(mainWindow=self) #This does not show, it only creates. Showing is decided in self.start
  103. self.ui.menubar.setNativeMenuBar(False) #Force a real menu bar. Qt on wayland will not display it otherwise.
  104. self.menu = Menu(mainWindow=self) #needs the about dialog, save file and the api.session ready.
  105. self.installEventFilter(self) #Bottom of the file. Works around the hover numpad bug that is in Qt for years.
  106. self.initiGuiSharedDataToSave()
  107. def start(self):
  108. api.session.eventLoop.start() #The event loop must be started after the qt app
  109. api.session.eventLoop.fastConnect(self.nsmClient.reactToMessage)
  110. api.startEngine(self.nsmClient) #Load the file, start the eventLoop. Triggers all the callbacks that makes us draw.
  111. if api.session.guiWasSavedAsNSMVisible:
  112. self.showGUI()
  113. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  114. if settings.contains("showAboutDialog") and settings.value("showAboutDialog", type=bool):
  115. 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
  116. elif not self.nsmClient.sessionName == "NOT-A-SESSION": #standalone mode
  117. self.ui.actionQuit.setShortcut("")
  118. #TODO: this is a hack until we figure out how to cleanly handle hide vs quite from outside the application
  119. self.hideGUI()
  120. self._zoom() #enable zoom factor loaded from save file
  121. #def event(self, event):
  122. # print (event.type())
  123. # return super().event(event)
  124. def callback_message(self, title, text):
  125. QtWidgets.QMessageBox.warning(self, title ,text)
  126. def dragEnterEvent(self, event):
  127. """Needs self.setAcceptDrops(True) in init"""
  128. if event.mimeData().hasUrls():
  129. event.accept()
  130. else:
  131. event.ignore()
  132. def dropEvent(self, event):
  133. """Needs self.setAcceptDrops(True) in init.
  134. Having that function in the mainWindow will not make drops available for subwindows
  135. like About or UserManual. """
  136. for url in event.mimeData().urls():
  137. filePath = url.toLocalFile()
  138. #Decide here if you want only files, only directories, both etc.
  139. if os.path.isfile(filePath) and filePath.lower().endswith(".sf2"):
  140. #linkedPath = self.nsmClient.importResource(filePath)
  141. print ("received drop")
  142. def storeWindowSettings(self):
  143. """Window state is not saved in the real save file. That would lead to portability problems
  144. between computers, like different screens and resolutions.
  145. For convenience that means we just use the damned qt settings and save wherever qt wants.
  146. We don't use the NSM id, session share their placement.
  147. bottom line: get a tiling window manager.
  148. """
  149. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  150. settings.setValue("geometry", self.saveGeometry())
  151. settings.setValue("windowState", self.saveState())
  152. def restoreWindowSettings(self):
  153. """opposite of storeWindowSettings. Read there."""
  154. settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
  155. if settings.contains("geometry") and settings.contains("windowState"):
  156. self.restoreGeometry(settings.value("geometry"))
  157. self.restoreState(settings.value("windowState"))
  158. def initiGuiSharedDataToSave(self):
  159. """Called by init"""
  160. if not "last_export_dir" in api.session.guiSharedDataToSave:
  161. api.session.guiSharedDataToSave["last_export_dir"] = os.path.expanduser("~")
  162. if "grid_opacity" in api.session.guiSharedDataToSave:
  163. constantsAndConfigs.gridOpacity = float(api.session.guiSharedDataToSave["grid_opacity"])
  164. if "grid_rhythm" in api.session.guiSharedDataToSave:
  165. #setting this is enough. When the grid gets created it fetches the constantsAndConfigs value.
  166. #Set only in submenus.GridRhytmEdit
  167. constantsAndConfigs.gridRhythm = int(api.session.guiSharedDataToSave["grid_rhythm"])
  168. #Stretch
  169. if "ticks_to_pixel_ratio" in api.session.guiSharedDataToSave:
  170. #setting this is enough. Drawing on startup uses the constantsAndConfigs value.
  171. #Set only in ScoreView._stretchXCoordinates
  172. constantsAndConfigs.ticksToPixelRatio = float(api.session.guiSharedDataToSave["ticks_to_pixel_ratio"])
  173. if "zoom_factor" in api.session.guiSharedDataToSave:
  174. #setting this is enough. Drawing on startup uses the constantsAndConfigs value.
  175. #Set only in ScoreView._zoom
  176. constantsAndConfigs.zoomFactor = float(api.session.guiSharedDataToSave["zoom_factor"])
  177. #Zoom and Stretch
  178. def wheelEvent(self, ev):
  179. modifiers = QtWidgets.QApplication.keyboardModifiers()
  180. if modifiers == QtCore.Qt.ControlModifier:
  181. ev.accept()
  182. if ev.angleDelta().y() > 0:
  183. self.zoomIn()
  184. else:
  185. self.zoomOut()
  186. elif modifiers == QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier:
  187. ev.accept()
  188. if ev.angleDelta().y() > 0:
  189. self.widen()
  190. else:
  191. self.shrinken()
  192. else:
  193. super().wheelEvent(ev) #send to the items
  194. def _zoom(self):
  195. api.session.guiSharedDataToSave["zoom_factor"] = constantsAndConfigs.zoomFactor
  196. #Send to client mainWindow
  197. self.zoom(constantsAndConfigs.zoomFactor)
  198. def zoom(self, scaleFactor:float):
  199. raise NotImplementedError("Reimplement this in your program. Can do nothing, if you want. See template/qtgui/mainwindow.py for example implementation")
  200. #self.scoreView.resetTransform()
  201. #self.scoreView.scale(scaleFactor, scaleFactor)
  202. def stretchXCoordinates(self, factor):
  203. raise NotImplementedError("Reimplement this in your program. Can do nothing, if you want. See template/qtgui/mainwindow.py for example implementation")
  204. #self.scoreView.stretchXCoordinates(factor)
  205. #self.scoreView.centerOnCursor()
  206. def zoomIn(self):
  207. constantsAndConfigs.zoomFactor = round(constantsAndConfigs.zoomFactor + 0.25, 2)
  208. if constantsAndConfigs.zoomFactor > 2.5:
  209. constantsAndConfigs.zoomFactor = 2.5
  210. self._zoom()
  211. return True
  212. def zoomOut(self):
  213. constantsAndConfigs.zoomFactor = round(constantsAndConfigs.zoomFactor - 0.25, 2)
  214. if constantsAndConfigs.zoomFactor < constantsAndConfigs.maximumZoomOut:
  215. constantsAndConfigs.zoomFactor = constantsAndConfigs.maximumZoomOut
  216. self._zoom()
  217. return True
  218. def zoomNull(self):
  219. constantsAndConfigs.zoomFactor = 1
  220. self._zoom()
  221. def _stretchXCoordinates(self, factor):
  222. """Reposition the items on the X axis.
  223. Call goes through all parents/children, starting from here.
  224. The parent sets the X coordinates of its children.
  225. Then the parent calls the childs _stretchXCoordinates() method if the child has children
  226. itself. For example a rectangleItem has a position which is set by the parent. But the
  227. rectangleItem has a right border which needs to be adjusted as well. This right border is
  228. treated as child of the rectItem, handled by rectItem._stretchXCoordinates(factor).
  229. """
  230. if self.xFactor * factor < 0.015:
  231. return
  232. self.xFactor *= factor
  233. constantsAndConfigs.ticksToPixelRatio /= factor
  234. api.session.guiSharedDataToSave["ticks_to_pixel_ratio"] = constantsAndConfigs.ticksToPixelRatio
  235. self.stretchXCoordinates(factor)
  236. return True
  237. def widen(self):
  238. #self._stretchXCoordinates(1*1.2) #leads to rounding errors
  239. self._stretchXCoordinates(2)
  240. def shrinken(self):
  241. #self._stretchXCoordinates(1/1.2) #leads to rounding errors
  242. self._stretchXCoordinates(0.5)
  243. #Close and exit
  244. def _nsmQuit(self, ourPath, sessionName, ourClientNameUnderNSM):
  245. logger.info("Qt main window received NSM exit callback. Calling pythons system exit. ")
  246. self.storeWindowSettings()
  247. #api.stopEngine() #will be called trough sessions atexit
  248. #self.qtApp.quit() #does not work. This will fail and pynsmclient2 will send SIGKILL
  249. sysexit() #works, NSM cleanly detects a quit. Triggers the session atexit condition
  250. logger.error("Code executed after sysexit. This message should not have been visible.")
  251. #Code here never gets executed.
  252. def closeEvent(self, event):
  253. """This is the manual close event, not the NSM Message.
  254. Ignore. We use it to send the GUI into hiding."""
  255. event.ignore()
  256. self.hideGUI()
  257. def hideGUI(self):
  258. self.storeWindowSettings()
  259. self.hide()
  260. self.nsmClient.announceGuiVisibility(False)
  261. def showGUI(self):
  262. self.restoreWindowSettings()
  263. self.show()
  264. self.nsmClient.announceGuiVisibility(True)
  265. def eventFilter(self, obj, event):
  266. """Qt has a known but unresolved bug (for many years now)
  267. that shortcuts with the numpad don't trigger. This has little
  268. chance of ever getting fixed, despite getting reported multiple
  269. times.
  270. Try to uninstall this filter (self.__init__) after a new qt release
  271. and check if it works without. It should (it did already in the past)
  272. but so far no luck...
  273. What do we do?
  274. We intercept every key and see if it was with a numpad modifier.
  275. If yes we trigger the menu action ourselves and discard the event
  276. We also need to separate the action of assigning a hover shortcut
  277. to a menu and actually triggering it. Since we are in the template
  278. part of the program we can't assume there is a main widget,
  279. as in Patroneo and Laborejos case.
  280. The obj is always MainWindow, so we can't detect if the menu was open or not.
  281. self.qtApp.focusWidget() is also NOT the menu.
  282. The menu is only triggered AFTER the filter. But we need to
  283. detect if a menu is open while we are running the filter.
  284. self.menu.ui.menubar.activeAction()
  285. For each key press/release we receive 3 keypress events, not
  286. counting autoRepeats. event.type() which is different from type(event)
  287. returns 51 (press), 6(release), 7(accept) for the three. This is key press, release and event accept(?).
  288. This event filter somehow does not differentiate between numpad
  289. on or numpad off. There maybe is another qt check for that.
  290. """
  291. 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":
  292. action = self.menu.hoverShortcutDict[event.text()]
  293. if action:
  294. action.trigger()
  295. event.accept() #despite what Qt docs say it is not enough to return True to "eat" the event.
  296. return True
  297. else:
  298. #No keypad shortcut. Just use the normal handling.
  299. return super().eventFilter(obj, event)