#! /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 #Engine import engine.api as api import engine.findicons as findicons #Qt from .descriptiontextwidget import DescriptionController from .helper import iconFromString iconSize = QtCore.QSize(16,16) class ClientItem(QtWidgets.QTreeWidgetItem): """ Item on the right side. Clients of the session, in various states. clientDict = { "clientId":clientId, #for convenience, included internally as well "dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this. "reportedName":None, #str "label":None, #str "lastStatus":None, #str "statusHistory":[], #list "hasOptionalGUI": False, #bool "visible": None, # bool "dirty": None, # bool } """ allItems = {} # clientId : ClientItem def __init__(self, parentController, clientDict:dict): ClientItem.allItems[clientDict["clientId"]] = self self.parentController = parentController self.clientDict = clientDict parameterList = [] #later in update super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type) self.defaultFlags = self.flags() self.setFlags(self.defaultFlags | QtCore.Qt.ItemIsEditable) #We have editTrigger to none so we can explicitly allow to only edit the name column on menuAction #self.treeWidget() not ready at this point self.updateData(clientDict) self.updateIcon(clientDict) def dataClientNameOverride(self, name:str): """Either string or None. If None we reset to nsmd name""" logger.info(f"Custom name for id {self.clientDict['clientId']} {self.clientDict['reportedName']}: {name}") if name: text = name else: text = self.clientDict["reportedName"] index = self.parentController.clientsTreeWidgetColumns.index("reportedName") self.setText(index, text) def updateData(self, clientDict:dict): """Arrives via parenTreeWidget api callback statusChanged, which is nsm status changed""" self.clientDict = clientDict for index, key in enumerate(self.parentController.clientsTreeWidgetColumns): if clientDict[key] is None: t = "" else: value = clientDict[key] if key == "visible": if value == True: t = "✔" else: t = "✖" elif key == "dirty": if value == True: t = QtCore.QCoreApplication.translate("OpenSession", "not saved") else: t = QtCore.QCoreApplication.translate("OpenSession", "clean") elif key == "reportedName" and self.parentController.clientOverrideNamesCache: if clientDict["clientId"] in self.parentController.clientOverrideNamesCache: t = self.parentController.clientOverrideNamesCache[clientDict["clientId"]] logger.info(f"Update Data: custom name for id {self.clientDict['clientId']} {self.clientDict['reportedName']}: {t}") else: t = str(value) else: t = str(value) self.setText(index, t) nameColumn = self.parentController.clientsTreeWidgetColumns.index("reportedName") if clientDict["reportedName"] is None: self.setText(nameColumn, clientDict["executable"]) def updateIcon(self, clientDict:dict): """Just called during init""" programIcons = self.parentController.mainWindow.programIcons assert programIcons assert "executable" in clientDict, clientDict iconColumn = self.parentController.clientsTreeWidgetColumns.index("reportedName") if clientDict["executable"] in programIcons: icon = programIcons[clientDict["executable"]] assert icon, icon self.setIcon(iconColumn, icon) #reported name is correct here. this is just the column. else: #Not NSM client added by the prompt widget result = findicons.findIconPath(clientDict["executable"]) if result: icon = QtGui.QIcon.fromTheme(str(result[0])) else: icon = QtGui.QIcon.fromTheme(clientDict["executable"]) if not icon.isNull(): self.setIcon(iconColumn, icon) else: self.setIcon(iconColumn, iconFromString(clientDict["executable"])) class ClientTable(object): """Controls the QTreeWidget that holds loaded clients""" def __init__(self, mainWindow, parent): self.mainWindow = mainWindow self.parent = parent self.clientOverrideNamesCache = None #None or dict. Dict is never truly empty, it has at least empty categories. self.sortByColumnValue = 0 #by name self.sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending self.clientsTreeWidget = self.mainWindow.ui.loadedSessionClients self.clientsTreeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.clientsTreeWidget.customContextMenuRequested.connect(self.clientsContextMenu) self.clientsTreeWidget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.clientsTreeWidget.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.clientsTreeWidget.setIconSize(iconSize) self.clientsTreeWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) #We only allow explicit editing. self.clientsTreeWidgetColumns = ("reportedName", "label", "lastStatus", "visible", "dirty", "clientId") #basically an enum self.clientHeaderLabels = [ QtCore.QCoreApplication.translate("OpenSession", "Name"), QtCore.QCoreApplication.translate("OpenSession", "Label"), QtCore.QCoreApplication.translate("OpenSession", "Status"), QtCore.QCoreApplication.translate("OpenSession", "Visible"), QtCore.QCoreApplication.translate("OpenSession", "Changes"), QtCore.QCoreApplication.translate("OpenSession", "ID"), ] self.clientsTreeWidget.setHeaderLabels(self.clientHeaderLabels) self.clientsTreeWidget.setSortingEnabled(True) self.clientsTreeWidget.setAlternatingRowColors(True) #Signals self.clientsTreeWidget.currentItemChanged.connect(self._reactSignal_currentClientChanged) self.clientsTreeWidget.itemDoubleClicked.connect(self._reactSignal_itemDoubleClicked) #This is hide/show or restart and NOT edit self.clientsTreeWidget.itemDelegate().closeEditor.connect(self._reactSignal_itemEditingFinished) self.clientsTreeWidget.model().layoutAboutToBeChanged.connect(self._reactSignal_rememberSorting) #self.clientsTreeWidget.model().layoutChanged.connect(self._reactSignal_restoreSorting) #Convenience Signals to directly disable the client messages on gui instruction. #This is purely for speed and preventing the user from sending a signal while the session is shutting down self.mainWindow.ui.actionSessionAbort.triggered.connect(lambda: self._updateClientMenu(deactivate=True)) self.mainWindow.ui.actionSessionSaveAndClose.triggered.connect(lambda: self._updateClientMenu(deactivate=True)) #API Callbacks api.callbacks.sessionOpenLoading.append(self._cleanClients) api.callbacks.sessionOpenReady.append(self._updateClientMenu) api.callbacks.sessionClosed.append(lambda: self._updateClientMenu(deactivate=True)) api.callbacks.clientStatusChanged.append(self._reactCallback_clientStatusChanged) api.callbacks.dataClientNamesChanged.append(self._reactCallback_dataClientNamesChanged) def _adjustColumnSize(self): self.clientsTreeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue) for index in range(self.clientsTreeWidget.columnCount()): self.clientsTreeWidget.resizeColumnToContents(index) def _cleanClients(self, nsmSessionExportDict:dict): """Reset everything to the initial, empty state. We do not reset in in openReady because that signifies that the session is ready. And not in session closed because we want to setup data structures.""" ClientItem.allItems.clear() self.clientsTreeWidget.clear() def clientsContextMenu(self, qpoint): """Reuses the menubar menus""" pos = QtGui.QCursor.pos() pos.setY(pos.y() + 5) item = self.clientsTreeWidget.itemAt(qpoint) if not type(item) is ClientItem: self.mainWindow.ui.menuSession.exec_(pos) return if not item is self.clientsTreeWidget.currentItem(): #Some mouse combinations can lead to getting a different context menu than the clicked item. self.clientsTreeWidget.setCurrentItem(item) menu = self.mainWindow.ui.menuClientNameId menu.exec_(pos) def _startEditingName(self, *args): currentItem = self.clientsTreeWidget.currentItem() self.editableItem = currentItem column = self.clientsTreeWidgetColumns.index("reportedName") self.clientsTreeWidget.editItem(currentItem, column) def _reactSignal_itemEditingFinished(self, qLineEdit, returnCode): """This is a hacky signal. It arrives every change, programatically or manually. We therefore only connect this signal right after a double click and disconnect it afterwards. And we still need to block signals while this is running. returnCode: no clue? Integers all over the place... """ treeWidgetItem = self.editableItem self.editableItem = None self.clientsTreeWidget.blockSignals(True) if treeWidgetItem: #We send the signal directly. Updating is done via callback. newName = treeWidgetItem.text(0) if not newName == treeWidgetItem.clientDict["reportedName"]: api.clientNameOverride(treeWidgetItem.clientDict["clientId"], newName) self.clientsTreeWidget.blockSignals(False) def _reactSignal_currentClientChanged(self, treeWidgetItem, previousItem): """Cache the current id for the client menu and shortcuts""" if treeWidgetItem: self.currentClientId = treeWidgetItem.clientDict["clientId"] else: self.currentClientId = None self._updateClientMenu() def _reactSignal_itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int): if item.clientDict["lastStatus"] == "stopped": api.clientResume(item.clientDict["clientId"]) elif item.clientDict["hasOptionalGUI"]: api.clientToggleVisible(item.clientDict["clientId"]) def _reactCallback_clientStatusChanged(self, clientDict:dict): """The major client callback. Maps to nsmd status changes. We will create and delete client tableWidgetItems based on this """ assert clientDict clientId = clientDict["clientId"] if clientId in ClientItem.allItems: if clientDict["lastStatus"] == "removed": index = self.clientsTreeWidget.indexOfTopLevelItem(ClientItem.allItems[clientId]) self.clientsTreeWidget.takeTopLevelItem(index) del ClientItem.allItems[clientId] else: ClientItem.allItems[clientId].updateData(clientDict) self._updateClientMenu() #Update here is fine because shutdown sets to status removed. else: #Create new. Item will be parented by Qt, so Python GC will not delete item = ClientItem(parentController=self, clientDict=clientDict) self.clientsTreeWidget.addTopLevelItem(item) self._adjustColumnSize() #Do not put a general menuUpdate here. It will re-open the client menu during shutdown, enabling the user to send false commands to the client. def _reactCallback_dataClientNamesChanged(self, clientOverrideNames:dict): """We either expect a dict or None. If None we return after clearing the data. We clear every callback and re-build. The dict can be content-empty of course.""" logger.info(f"Received dataStorage names update: {clientOverrideNames}") #Clear current GUI data. for clientInstance in ClientItem.allItems.values(): clientInstance.dataClientNameOverride(None) if clientOverrideNames is None: #This only happens if there was a client present and that exits. self.clientOverrideNamesCache = None else: #Real data #assert "origin" in data, data . Not in a fresh session, after adding! #assert data["origin"] == "https://www.laborejo.org/agordejo/nsm-data", data["origin"] self.clientOverrideNamesCache = clientOverrideNames #Can be empty dict as well clients = ClientItem.allItems for clientId, name in clientOverrideNames.items(): #It is possible on session start, that a client has not yet loaded but we already receive a name override. nsm-data is instructed to only announce after session has loaded, but that can go wrong when nsmd has a bad day. #Long story short: better to not rename right now, have some name mismatch and wait for a general update later, which will happen after every client load anyway. if clientId in clients: clients[clientId].dataClientNameOverride(name) self._updateClientMenu() #Update because we need to en/disable the rename action self._adjustColumnSize() def _updateClientMenu(self, deactivate=False): """The client menu changes with every currentItem edit to reflect the name and capabilities""" ui = self.mainWindow.ui menu = ui.menuClientNameId if deactivate: currentItem = None else: currentItem = self.clientsTreeWidget.currentItem() if currentItem: clientId = currentItem.clientDict["clientId"] state = True #if currentItem.clientDict["label"]: # name = currentItem.clientDict["label"] #else: # name = currentItem.clientDict["reportedName"] name = currentItem.text(self.clientsTreeWidgetColumns.index("reportedName")) else: state = False name = "Client" menu.setTitle(name) #menu.setEnabled(state) #It is not enough to disable the menu itself. Shortcuts will still work. for action in menu.actions(): action.setEnabled(state) if state: ui.actionClientRename.triggered.disconnect() ui.actionClientRename.triggered.connect(self._startEditingName) #ui.actionClientRename.triggered.connect(lambda: self.clientsTreeWidget.editItem(currentItem, self.clientsTreeWidgetColumns.index("reportedName"))) ui.actionClientSave_separately.triggered.disconnect() ui.actionClientSave_separately.triggered.connect(lambda: api.clientSave(clientId)) ui.actionClientStop.triggered.disconnect() ui.actionClientStop.triggered.connect(lambda: api.clientStop(clientId)) #ui.actionClientStop.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback ui.actionClientResume.triggered.disconnect() ui.actionClientResume.triggered.connect(lambda: api.clientResume(clientId)) #ui.actionClientResume.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback ui.actionClientRemove.triggered.disconnect() ui.actionClientRemove.triggered.connect(lambda: api.clientRemove(clientId)) #Deactivate depending on the state of the program if currentItem.clientDict["lastStatus"] == "stopped": ui.actionClientSave_separately.setEnabled(False) ui.actionClientStop.setEnabled(False) ui.actionClientToggleVisible.setEnabled(False) ui.actionClientResume.setEnabled(True) else: ui.actionClientResume.setEnabled(False) #Hide and show shall only be enabled and connected if supported by the client try: ui.actionClientToggleVisible.triggered.disconnect() except TypeError: #TypeError: disconnect() failed between 'triggered' and all its connections pass if currentItem.clientDict["hasOptionalGUI"]: ui.actionClientToggleVisible.setEnabled(True) ui.actionClientToggleVisible.triggered.connect(lambda: api.clientToggleVisible(clientId)) else: ui.actionClientToggleVisible.setEnabled(False) #Only rename when dataclient is present #None or dict, even empty dict if self.clientOverrideNamesCache is None: ui.actionClientRename.setEnabled(False) else: ui.actionClientRename.setEnabled(True) def _reactSignal_rememberSorting(self, *args): self.sortByColumnValue = self.clientsTreeWidget.header().sortIndicatorSection() self.sortDescendingValue = self.clientsTreeWidget.header().sortIndicatorOrder() def _reactSignal_restoreSorting(self, *args): """Do not use as signal!!! Will lead to infinite recursion since Qt 5.12.2""" #self.clientsTreeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue) raise RuntimeError() class LauncherProgram(QtWidgets.QTreeWidgetItem): """ An item on the left side of the window. Used to start programs and show info, but nothing more. Example: { 'categories': 'AudioVideo;Audio;X-Recorders;X-Multitrack;X-Jack;', 'comment': 'Easy to use pattern sequencer for JACK and NSM', 'comment[de]': 'Einfach zu bedienender Pattern-Sequencer', 'exec': 'patroneo', 'genericname': 'Sequencer', 'icon': 'patroneo', 'name': 'Patroneo', 'startupnotify': 'false', 'terminal': 'false', 'type': 'Application', 'version': '1.0', #desktop spec version, not progra, 'x-nsm-capable': 'true'} Also: 'agordejoExec' : the actual nsm exe 'agordejoIconPath' : a priority path the engine found for us """ allItems = {} # clientId : ClientItem def __init__(self, parentController, launcherDict:dict): LauncherProgram.allItems[launcherDict["agordejoExec"]] = self self.parentController = parentController self.launcherDict = launcherDict self.executable = launcherDict["agordejoExec"] parameterList = [] #later in update super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type) self.updateData(launcherDict) def updateData(self, launcherDict:dict): """Arrives via parenTreeWidget api callback""" self.launcherDict = launcherDict for index, key in enumerate(self.parentController.columns): if (not key in launcherDict) or launcherDict[key] is None: t = "" else: t = str(launcherDict[key]) self.setText(index, t) programIcons = self.parentController.mainWindow.programIcons assert programIcons if launcherDict["agordejoExec"] in programIcons: icon = programIcons[launcherDict["agordejoExec"]] self.setIcon(self.parentController.columns.index("name"), icon) #name is correct here. this is just the column. class LauncherTable(object): """Controls the QTreeWidget that holds programs in the PATH. """ def __init__(self, mainWindow, parent): self.mainWindow = mainWindow self.parent = parent self.sortByColumnValue = 0 # by name self.sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending self.launcherWidget = self.mainWindow.ui.loadedSessionsLauncher self.launcherWidget.setIconSize(iconSize) self.columns = ("name", "comment", "agordejoFullPath") #basically an enum self.headerLables = [ QtCore.QCoreApplication.translate("Launcher", "Name"), QtCore.QCoreApplication.translate("Launcher", "Description"), QtCore.QCoreApplication.translate("Launcher", "Path"), ] self.launcherWidget.setHeaderLabels(self.headerLables) self.launcherWidget.setSortingEnabled(True) self.launcherWidget.setAlternatingRowColors(True) self.launcherWidget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.launcherWidget.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) ##The actual program entries are handled by the LauncherProgram item class #self.buildPrograms() #Don't call here. MainWindow calls it when everything is ready. #Signals self.launcherWidget.itemDoubleClicked.connect(self._reactSignal_launcherItemDoubleClicked) def _adjustColumnSize(self): self.launcherWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue) for index in range(self.launcherWidget.columnCount()): self.launcherWidget.resizeColumnToContents(index) def _reactSignal_launcherItemDoubleClicked(self, item): api.clientAdd(item.executable) def buildPrograms(self): """Called by mainWindow.updateProgramDatabase Receive entries from the engine. Entry is a dict modelled after a .desktop file. But not all entries have all data. Some are barebones executable name and path. Only guaranteed keys are agordejoExec and agordejoFullPath, which in turn are files guaranteed to exist in the path. """ self.launcherWidget.clear() engineCache = api.getCache() programs = engineCache["programs"] for entry in programs: item = LauncherProgram(parentController=self, launcherDict=entry) self.launcherWidget.addTopLevelItem(item) self._adjustColumnSize() class OpenSessionController(object): """Not a subclass. Controls the visible tab, when a session is open. There is only one open instance at a time that controls the GUI and cleans itself.""" def __init__(self, mainWindow): self.mainWindow = mainWindow self.clientTabe = ClientTable(mainWindow=mainWindow, parent=self) self.launcherTable = LauncherTable(mainWindow=mainWindow, parent=self) self.descriptionController = DescriptionController(mainWindow, self.mainWindow.ui.loadedSessionDescriptionGroupBox, self.mainWindow.ui.loadedSessionDescription) self.sessionLoadedPanel = mainWindow.ui.session_loaded #groupbox self.sessionProgramsPanel = mainWindow.ui.session_programs #groupbox #API Callbacks api.callbacks.sessionOpenReady.append(self._reactCallback_sessionOpen) logger.info("Full View Open Session Controller ready") def _reactCallback_sessionOpen(self, nsmSessionExportDict:dict): """Open does not mean we come from the session chooser. Switching does not close a session""" #self.description.clear() #Deletes the placesholder and text! self.sessionLoadedPanel.setTitle(nsmSessionExportDict["nsmSessionName"])