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.

516 lines
24 KiB

4 years ago
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
2 years ago
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
4 years ago
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
4 years ago
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
import xdg.IconTheme #pyxdg https://www.freedesktop.org/wiki/Software/pyxdg/
4 years ago
#Engine
import engine.api as api
#Qt
from .descriptiontextwidget import DescriptionController
from .helper import iconFromString
4 years ago
iconSize = QtCore.QSize(16,16)
class ClientItem(QtWidgets.QTreeWidgetItem):
"""
Item on the right side. Clients of the session, in various states.
4 years ago
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)
4 years ago
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"""
4 years ago
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 = ""
4 years ago
else:
t = ""
4 years ago
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)
4 years ago
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"""
4 years ago
programIcons = self.parentController.mainWindow.programIcons
if not programIcons:
return #later again.
4 years ago
assert "executable" in clientDict, clientDict
iconColumn = self.parentController.clientsTreeWidgetColumns.index("reportedName")
4 years ago
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 = xdg.IconTheme.getIconPath(clientDict["executable"]) #First attempt: let's hope the icon name has something to do with the executable name
if result:
icon = QtGui.QIcon.fromTheme(result)
else:
icon = QtGui.QIcon.fromTheme(clientDict["executable"])
if not icon.isNull():
self.setIcon(iconColumn, icon)
else:
self.setIcon(iconColumn, iconFromString(clientDict["executable"])) #draw our own.
4 years ago
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
4 years ago
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)
4 years ago
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
4 years ago
self.clientsTreeWidget.itemDelegate().closeEditor.connect(self._reactSignal_itemEditingFinished)
self.clientsTreeWidget.model().layoutAboutToBeChanged.connect(self._reactSignal_rememberSorting)
#self.clientsTreeWidget.model().layoutChanged.connect(self._reactSignal_restoreSorting)
4 years ago
#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)
4 years ago
for index in range(self.clientsTreeWidget.columnCount()):
self.clientsTreeWidget.resizeColumnToContents(index)
#And a bit more extra space
for index in range(self.clientsTreeWidget.columnCount()):
self.clientsTreeWidget.setColumnWidth(index, self.clientsTreeWidget.columnWidth(index)+25)
4 years ago
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 allItems(self):
return ClientItem.allItems
4 years ago
def clientsContextMenu(self, qpoint):
"""Reuses the menubar menus"""
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
4 years ago
item = self.clientsTreeWidget.itemAt(qpoint)
if not type(item) is ClientItem:
self.mainWindow.ui.menuSession.exec_(pos)
4 years ago
return
4 years ago
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
4 years ago
menu.exec_(pos)
def _startEditingName(self, *args):
currentItem = self.clientsTreeWidget.currentItem()
4 years ago
self.editableItem = currentItem
column = self.clientsTreeWidgetColumns.index("reportedName")
self.clientsTreeWidget.editItem(currentItem, column)
4 years ago
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
4 years ago
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"]:
4 years ago
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
"""
4 years ago
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]
4 years ago
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"]
4 years ago
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)
4 years ago
else:
ui.actionClientResume.setEnabled(False)
4 years ago
#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)
4 years ago
else:
ui.actionClientRename.setEnabled(True)
def _reactSignal_rememberSorting(self, *args):
self.sortByColumnValue = self.clientsTreeWidget.header().sortIndicatorSection()
self.sortDescendingValue = self.clientsTreeWidget.header().sortIndicatorOrder()
4 years ago
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()
4 years ago
class LauncherProgram(QtWidgets.QTreeWidgetItem):
"""
An item on the left side of the window. Used to start programs and show info, but nothing more.
4 years ago
"""
allItems = {} # clientId : ClientItem
def __init__(self, parentController, launcherDict:dict):
LauncherProgram.allItems[launcherDict["agordejoExec"]] = self
4 years ago
self.parentController = parentController
self.launcherDict = launcherDict
self.executable = launcherDict["agordejoExec"]
4 years ago
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
if not programIcons:
return #later again
if launcherDict["agordejoExec"] in programIcons:
icon = programIcons[launcherDict["agordejoExec"]]
self.setIcon(self.parentController.columns.index("agordejoName"), icon) #name is correct here. this is just the column.
4 years ago
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
4 years ago
self.launcherWidget = self.mainWindow.ui.loadedSessionsLauncher
self.launcherWidget.setIconSize(iconSize)
self.columns = ("agordejoName", "agordejoDescription", "agordejoFullPath") #basically an enum
4 years ago
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.
4 years ago
#Signals
self.launcherWidget.itemDoubleClicked.connect(self._reactSignal_launcherItemDoubleClicked)
def _adjustColumnSize(self):
self.launcherWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue)
4 years ago
for index in range(self.launcherWidget.columnCount()):
self.launcherWidget.resizeColumnToContents(index)
#And a bit more extra space
for index in range(self.launcherWidget.columnCount()):
self.launcherWidget.setColumnWidth(index, self.launcherWidget.columnWidth(index)+25)
4 years ago
def _reactSignal_launcherItemDoubleClicked(self, item):
api.clientAdd(item.executable)
def buildPrograms(self):
"""Called by mainWindow.updateProgramDatabase
Receive entries from the engine.
"""
self.launcherWidget.clear()
programs = api.getNsmClients()
4 years ago
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)
4 years ago
self.sessionLoadedPanel = mainWindow.ui.session_loaded #groupbox
self.sessionProgramsPanel = mainWindow.ui.session_programs #groupbox
#API Callbacks
api.callbacks.sessionOpenReady.append(self._reactCallback_sessionOpen)
4 years ago
logger.info("Full View Open Session Controller ready")
def allSessionItems(self):
"""Can be used by external parts, like the tray icon"""
return self.clientTabe.allItems().values() #dict clientId:SessionItem
4 years ago
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!
4 years ago
self.sessionLoadedPanel.setTitle(nsmSessionExportDict["nsmSessionName"])