#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2021, 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 . """ import logging; logger = logging.getLogger(__name__); logger.info("import") #Standard Library #Third Party from PyQt5 import QtCore, QtGui, QtWidgets #Template Modules from .about import About from .changelog import Changelog from .resources import * #User Modules import engine.api as api from engine.config import * #imports METADATA class Menu(object): def __init__(self, mainWindow): """We can receive a dict of extra menu actions, which also override our hardcoded ones""" self.mainWindow = mainWindow self.ui = mainWindow.ui #just shorter to write. #Hover Shortcuts self.hoverShortcutDict = HoverActionDictionary(self.mainWindow) #for temporary hover shortcuts. key=actual key, value=QAction self.lastHoverAction = None #the last time the mouse hovered over a menu it was this QAction. This gets never empty again, but works because we only check it during menu KeyPressEvent def _rememberHover(action): self.lastHoverAction = action self.ui.menubar.hovered.connect(_rememberHover) self._originalMenuKeyPressEvent = self.ui.menubar.keyPressEvent self.ui.menubar.keyPressEvent = self.menuKeyPressInterceptor #Order Matters. First is inserted first self._setupFileMenu() self._setupEditMenu() self._setupHelpMenu() self._setupDebugMenu() #Setup the provided debug menu, keep it separate from actual code #Set the label of undo and redo to show the info the api provides api.callbacks.historyChanged.append(self.renameUndoRedoByHistory) def renameUndoRedoByHistory(self, undoList, redoList): undoString = QtCore.QCoreApplication.translate("TemplateMainWindow", "Undo") redoString = QtCore.QCoreApplication.translate("TemplateMainWindow", "Redo") if undoList: undoInfo = QtCore.QCoreApplication.translate("NOOPengineHistory", undoList[-1]) self.ui.actionUndo.setText(undoString +": {}".format(undoInfo)) self.ui.actionUndo.setEnabled(True) else: self.ui.actionUndo.setText(undoString) self.ui.actionUndo.setEnabled(False) if redoList: redoInfo = QtCore.QCoreApplication.translate("NOOPengineHistory", redoList[-1]) self.ui.actionRedo.setText(redoString + ": {}".format(redoInfo)) self.ui.actionRedo.setEnabled(True) else: self.ui.actionRedo.setText(redoString) self.ui.actionRedo.setEnabled(False) def _setupHelpMenu(self): self.addSubmenu("menuHelp", QtCore.QCoreApplication.translate("TemplateMainWindow", "Help")) self.addMenuEntry("menuHelp", "actionAbout", QtCore.QCoreApplication.translate("TemplateMainWindow", "About and Tips"), connectedFunction=self.mainWindow.about.show) self.addMenuEntry("menuHelp", "actionUser_Manual", QtCore.QCoreApplication.translate("TemplateMainWindow", "User Manual"), connectedFunction=self.mainWindow.userManual.show) self.addMenuEntry("menuHelp", "actionChangelog", QtCore.QCoreApplication.translate("TemplateMainWindow", "News and Changelog"), connectedFunction=self.mainWindow.changelog.show) def _setupEditMenu(self): self.addSubmenu("menuEdit", QtCore.QCoreApplication.translate("TemplateMainWindow", "Edit")) self.addMenuEntry("menuEdit", "actionUndo", QtCore.QCoreApplication.translate("TemplateMainWindow", "Undo"), connectedFunction=api.undo, shortcut="Ctrl+Z") self.addMenuEntry("menuEdit", "actionRedo", QtCore.QCoreApplication.translate("TemplateMainWindow", "Redo"), connectedFunction=api.redo, shortcut="Ctrl+Shift+Z") def _setupFileMenu(self): self.addSubmenu("menuFile", QtCore.QCoreApplication.translate("TemplateMainWindow", "File")) self.addMenuEntry("menuFile", "actionSave", QtCore.QCoreApplication.translate("TemplateMainWindow", "Save"), connectedFunction=api.save, shortcut="Ctrl+S") self.addMenuEntry("menuFile", "actionQuit", QtCore.QCoreApplication.translate("TemplateMainWindow", "Quit (without saving)"), connectedFunction=self.mainWindow.nsmClient.serverSendExitToSelf, shortcut="Ctrl+Q") def _setupDebugMenu(self): """Debug entries are not translated""" def guiCallback_menuDebugVisibility(state): self.ui.menuDebug.menuAction().setVisible(state) api.session.guiSharedDataToSave["actionToggle_Debug_Menu_Visibility"] = state self.addSubmenu("menuDebug", "Debug") self.addMenuEntry("menuDebug", "actionToggle_Debug_Menu_Visibility", "Toggle Debug Menu Visibility", connectedFunction=guiCallback_menuDebugVisibility, shortcut="Ctrl+Alt+Shift+D") self.ui.actionToggle_Debug_Menu_Visibility.setCheckable(True) self.ui.actionToggle_Debug_Menu_Visibility.setEnabled(True) self.addMenuEntry("menuDebug", "actionRun_Script_File", "Run Script File", connectedFunction=self.mainWindow.debugScriptRunner.run) #runs the file debugscript.py in our session dir. self.addMenuEntry("menuDebug", "actionEmpty_Menu_Entry", "Empty Menu Entry") deleteSessionText = "Delete our session data and exit: {}".format(self.mainWindow.nsmClient.ourPath) #Include the to-be-deleted path into the menu text. Better verbose than sorry. self.addMenuEntry("menuDebug", "actionReset_Own_Session_Data", deleteSessionText, connectedFunction=self.mainWindow.nsmClient.debugResetDataAndExit) #Now that the actions are connected we can create the initial setup if not "actionToggle_Debug_Menu_Visibility" in api.session.guiSharedDataToSave: api.session.guiSharedDataToSave["actionToggle_Debug_Menu_Visibility"] = False state = api.session.guiSharedDataToSave["actionToggle_Debug_Menu_Visibility"] self.ui.actionToggle_Debug_Menu_Visibility.setChecked(state) #does not trigger the connected action... self.ui.menuDebug.menuAction().setVisible(state) #...therefore we hide manually. def menuKeyPressInterceptor(self, event): """ Triggered when a menu is open and mouse hovers over a menu. Menu Entries without their own shortcut can be temporarily assigned one of the ten number keypad keys as shortcuts. If the numpad key is already in use it will be rerouted (freed first). """ strings = set(("Num+1", "Num+2", "Num+3", "Num+4", "Num+5", "Num+6", "Num+7", "Num+8", "Num+9", "Num+0")) if self.lastHoverAction and (not self.lastHoverAction.menu()) and event.modifiers() == QtCore.Qt.KeypadModifier and ((not self.lastHoverAction.shortcut().toString()) or self.lastHoverAction.shortcut().toString() in strings): key = event.text() #just number 0-9 #event.key() is 49, 50... self.hoverShortcutDict[key] = self.lastHoverAction event.accept() else: self._originalMenuKeyPressEvent(event) def addSubmenu(self, submenuActionAsString, text): """ Returns the submenu. submenuActionAsString is something like menuHelp It is not possible to create nested submenus yet. text must already be translated. If the menu already exists (decided by submenuActionAsString, not by text) it will be returned instead.""" try: return getattr(self.ui, submenuActionAsString) except AttributeError: pass setattr(self.ui, submenuActionAsString, QtWidgets.QMenu(self.ui.menubar)) menu = getattr(self.ui, submenuActionAsString) menu.setObjectName(submenuActionAsString) menu.menuAction().setObjectName(submenuActionAsString) menu.setTitle(text) self.ui.menubar.addAction(menu.menuAction()) return menu def getSubmenuOrder(self): """Returns a list of strings with submenu names, compatible with orderSubmenus""" #text() shows the translated string. We wan't the attribute name like actionEdit #action.objectName() gives us the name, but only if set. QtDesigner does not set. #action.menu().objectName() works. listOfStrings = [] for a in self.ui.menubar.actions(): if a.menu(): listOfStrings.append(a.menu().objectName()) else: #separator listOfStrings.append(None) return listOfStrings def getTemplateSubmenus(self): """Returns a list of strings with submenu names, compatible with orderSubmenus. Only contains the ones set in the template. Not in order.""" #Hardcoded. return ["menuFile", "menuEdit", "menuHelp", "menuDebug"] def getClientSubmenus(self): """opposite of getTemplateSubmenus. In order""" allMenus = self.getSubmenuOrder() templateMenus = self.getTemplateSubmenus() return [st for st in allMenus if st not in templateMenus] def orderSubmenus(self, listOfStrings): """sort after listOfStrings. List of strings can contain None to insert a separator at this position. It is expected that you give the complete list of menus.""" self.ui.menubar.clear() for submenuActionAsString in listOfStrings: if submenuActionAsString: menu = getattr(self.ui, submenuActionAsString) self.ui.menubar.addAction(menu.menuAction()) else: self.ui.menubar.addSeparator() def hideSubmenu(self, submenu:str): menuAction = getattr(self.ui, submenu).menuAction() menuAction.setVisible(False) def removeSubmenu(self, submenuAction:str): menuAction = getattr(self.ui, submenu).menuAction() raise NotImplementedError #TODO def addMenuEntry(self, submenu, actionAsString:str, text:str, connectedFunction=None, shortcut:str="", tooltip:str="", iconResource:str="", checkable=False, startChecked=False): """ parameterstrings must already be translated. Returns the QAction Add a new menu entry to an existing submenu. If you want a new submenu use the create submenu function first. submenu is the actual menu action name, like "menuHelp". You need to know the variable names. actionAsString is the unique action name including the word "action", text is the display name. Text must be already translated, actionAsString must never be translated. shortcut is an optional string like "Ctrl+Shift+Z" If the action already exists (decided by submenuActionAsString, not by text) it will be returned instead. """ try: return getattr(self.ui, actionAsString) except AttributeError: pass setattr(self.ui, actionAsString, QtWidgets.QAction(self.mainWindow)) action = getattr(self.ui, actionAsString) action.setObjectName(actionAsString) action.setText(text) #Text must already be translated action.setToolTip(tooltip) #Text must already be translated if iconResource: assert QtCore.QFile.exists(iconResource) ico = QtGui.QIcon(iconResource) action.setIcon(ico) if checkable: action.setCheckable(True) action.setChecked(startChecked) self.connectMenuEntry(actionAsString, connectedFunction, shortcut) submenu = getattr(self.ui, submenu) submenu.addAction(action) return action def hideMenuEntry(self, menuAction:str): action = getattr(self.ui, menuAction) action.setVisible(False) def removeMenuEntry(self, menuAction:str): raise NotImplementedError #TODO def addSeparator(self, submenu): submenu = getattr(self.ui, submenu) submenu.addSeparator() def connectMenuEntry(self, actionAsString, connectedFunction, shortcut=""): """ Returns the submenu. Disconnects the old action first shortcut is an optional string like "Ctrl+Shift+Z" You can give None for connectedFunction. That makes automatisation in the program easier. However it is possible to set a shortcut without a connectedFunction. Also this helps to disconnect a menu entry of all its function. """ action = getattr(self.ui, actionAsString) try: action.triggered.disconnect() except TypeError: #Action has no connected function on triggered-signal. pass if connectedFunction: action.triggered.connect(connectedFunction) if shortcut: action.setShortcut(shortcut) return action def disable(self): """This is meant for a short temporary disabling.""" for a in self.mainWindow.findChildren(QtWidgets.QAction): if not a.menu(): a.setEnabled(False) def enable(self): for a in self.mainWindow.findChildren(QtWidgets.QAction): a.setEnabled(True) self.renameUndoRedoByHistory(*api.getUndoLists()) class HoverActionDictionary(dict): """Key = number from 0-9 as string Value = QAction Singleton. """ def __init__(self, mainWindow, *args): self.mainWindow = mainWindow if not "hoverActionDictionary" in api.session.guiSharedDataToSave: # key = action.text() , value = key sequence as text 'Num3+' key. Yes, the plus is in the string # Basically both values and keys should be unique. api.session.guiSharedDataToSave["hoverActionDictionary"] = {} dict.__init__(self, args) self.qShortcuts = { "1" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_1, "2" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_2, "3" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_3, "4" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_4, "5" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_5, "6" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_6, "7" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_7, "8" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_8, "9" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_9, "0" : QtCore.Qt.KeypadModifier+QtCore.Qt.Key_0, } dict.__setitem__(self, "0", None) dict.__setitem__(self, "1", None) dict.__setitem__(self, "2", None) dict.__setitem__(self, "3", None) dict.__setitem__(self, "4", None) dict.__setitem__(self, "5", None) dict.__setitem__(self, "6", None) dict.__setitem__(self, "7", None) dict.__setitem__(self, "8", None) dict.__setitem__(self, "9", None) """ for i in range(10): emptyAction = QtWidgets.QAction("empty{}".format(i)) self.mainWindow.addAction(emptyAction) #no actions without a widget emptyAction.triggered.connect(api.nothing) emptyAction.setShortcut(QtGui.QKeySequence(self.qShortcuts[str(i)])) dict.__setitem__(self, str(i), emptyAction) """ #Load the hover shortcuts from last session. #We assume the shortcuts are clean and there is no mismatch or ambiguity. if "hoverActionDictionary" in api.session.guiSharedDataToSave: # key = action.text() , value = key sequence as text 'Num3+' key. Yes, the plus is in the string for action in self.mainWindow.findChildren(QtWidgets.QAction): #search the complete menu #TODO: if the menu label changes, maybe through translation, this will break. Non-critical, but annoying. Find a way to find unique ids. if action.text() in api.session.guiSharedDataToSave["hoverActionDictionary"]: #A saved action was found keySequenceAsString = api.session.guiSharedDataToSave["hoverActionDictionary"][action.text()] justTheNumberAsString = keySequenceAsString[-1 ] #-1 is a number as string action.setShortcut(QtGui.QKeySequence(self.qShortcuts[justTheNumberAsString])) dict.__setitem__(self, justTheNumberAsString, action) #does not trigger our own set item! def __setitem__(self, key, menuAction): """key is a string of a single number 0-9""" assert key in "0 1 2 3 4 5 6 7 8 9".split() try: self[key].setShortcut(QtGui.QKeySequence()) #reset the action that previously had that shortcut except AttributeError: pass #Remove old entry from save dict if shortcut already in there. shortcut is the value, so we have to do it by iteration for k,v in api.session.guiSharedDataToSave["hoverActionDictionary"].items(): if v == "Num+"+key: del api.session.guiSharedDataToSave["hoverActionDictionary"][k] break assert not key in api.session.guiSharedDataToSave["hoverActionDictionary"].values() try: dict.__setitem__(self, menuAction.shortcut().toString()[-1], None) #-1 is a number as string except IndexError: pass menuAction.setShortcut(QtGui.QKeySequence()) #reset the new action to remove existing shortcuts menuAction.setShortcut(QtGui.QKeySequence(self.qShortcuts[key])) api.session.guiSharedDataToSave["hoverActionDictionary"][menuAction.text()] = "Num+"+key dict.__setitem__(self, key, menuAction)