Music production session manager https://www.laborejo.org
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.
 
 

522 lines
24 KiB

#! /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 <http://www.gnu.org/licenses/>.
"""
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"])