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.

485 lines
23KB

  1. #! /usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Copyright 2020, 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. #Third Party
  20. from PyQt5 import QtCore, QtGui, QtWidgets
  21. #Engine
  22. import engine.api as api
  23. #Qt
  24. from .descriptiontextwidget import DescriptionController
  25. iconSize = QtCore.QSize(16,16)
  26. class ClientItem(QtWidgets.QTreeWidgetItem):
  27. """
  28. clientDict = {
  29. "clientId":clientId, #for convenience, included internally as well
  30. "dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this.
  31. "reportedName":None, #str
  32. "label":None, #str
  33. "lastStatus":None, #str
  34. "statusHistory":[], #list
  35. "hasOptionalGUI": False, #bool
  36. "visible": None, # bool
  37. "dirty": None, # bool
  38. }
  39. """
  40. allItems = {} # clientId : ClientItem
  41. def __init__(self, parentController, clientDict:dict):
  42. ClientItem.allItems[clientDict["clientId"]] = self
  43. self.parentController = parentController
  44. self.clientDict = clientDict
  45. parameterList = [] #later in update
  46. super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type)
  47. self.defaultFlags = self.flags()
  48. 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
  49. #self.treeWidget() not ready at this point
  50. self.updateData(clientDict)
  51. def dataClientNameOverride(self, name:str):
  52. """Either string or None. If None we reset to nsmd name"""
  53. logger.info(f"Custom name for id {self.clientDict['clientId']} {self.clientDict['reportedName']}: {name}")
  54. if name:
  55. text = name
  56. else:
  57. text = self.clientDict["reportedName"]
  58. index = self.parentController.clientsTreeWidgetColumns.index("reportedName")
  59. self.setText(index, text)
  60. def updateData(self, clientDict:dict):
  61. """Arrives via parenTreeWidget api callback"""
  62. self.clientDict = clientDict
  63. for index, key in enumerate(self.parentController.clientsTreeWidgetColumns):
  64. if clientDict[key] is None:
  65. t = ""
  66. else:
  67. value = clientDict[key]
  68. if key == "visible":
  69. if value == True:
  70. t = QtCore.QCoreApplication.translate("OpenSession", "✔")
  71. else:
  72. t = QtCore.QCoreApplication.translate("OpenSession", "✖")
  73. elif key == "dirty":
  74. if value == True:
  75. t = QtCore.QCoreApplication.translate("OpenSession", "not saved")
  76. else:
  77. t = QtCore.QCoreApplication.translate("OpenSession", "clean")
  78. elif key == "reportedName" and self.parentController.clientOverrideNamesCache:
  79. if clientDict["clientId"] in self.parentController.clientOverrideNamesCache:
  80. t = self.parentController.clientOverrideNamesCache[clientDict["clientId"]]
  81. logger.info(f"Update Data: custom name for id {self.clientDict['clientId']} {self.clientDict['reportedName']}: {t}")
  82. else:
  83. t = str(value)
  84. else:
  85. t = str(value)
  86. self.setText(index, t)
  87. programIcons = self.parentController.mainWindow.programIcons
  88. assert programIcons
  89. assert "executable" in clientDict, clientDict
  90. if clientDict["executable"] in programIcons:
  91. icon = programIcons[clientDict["executable"]]
  92. self.setIcon(self.parentController.clientsTreeWidgetColumns.index("reportedName"), icon) #reported name is correct here. this is just the column.
  93. nameColumn = self.parentController.clientsTreeWidgetColumns.index("reportedName")
  94. if clientDict["reportedName"] is None:
  95. self.setText(nameColumn, clientDict["executable"])
  96. #TODO: this should be an nsmd status. Check if excecutable exists. nsmd just reports "stopped", and worse: after a long timeout.
  97. if clientDict["lastStatus"] == "stopped" and clientDict["reportedName"] is None:
  98. self.setText(self.parentController.clientsTreeWidgetColumns.index("lastStatus"), QtCore.QCoreApplication.translate("OpenSession", "(command not found)"))
  99. class ClientTable(object):
  100. """Controls the QTreeWidget that holds loaded clients"""
  101. def __init__(self, mainWindow, parent):
  102. self.mainWindow = mainWindow
  103. self.parent = parent
  104. self.clientOverrideNamesCache = None #None or dict. Dict is never truly empty, it has at least empty categories.
  105. self.sortByColumn = 0 #by name
  106. self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
  107. self.clientsTreeWidget = self.mainWindow.ui.loadedSessionClients
  108. self.clientsTreeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
  109. self.clientsTreeWidget.customContextMenuRequested.connect(self.clientsContextMenu)
  110. self.clientsTreeWidget.setIconSize(iconSize)
  111. self.clientsTreeWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) #We only allow explicit editing.
  112. self.clientsTreeWidgetColumns = ("reportedName", "label", "lastStatus", "visible", "dirty", "clientId") #basically an enum
  113. self.clientHeaderLabels = [
  114. QtCore.QCoreApplication.translate("OpenSession", "Name"),
  115. QtCore.QCoreApplication.translate("OpenSession", "Label"),
  116. QtCore.QCoreApplication.translate("OpenSession", "Status"),
  117. QtCore.QCoreApplication.translate("OpenSession", "Visible"),
  118. QtCore.QCoreApplication.translate("OpenSession", "Changes"),
  119. QtCore.QCoreApplication.translate("OpenSession", "ID"),
  120. ]
  121. self.clientsTreeWidget.setHeaderLabels(self.clientHeaderLabels)
  122. self.clientsTreeWidget.setSortingEnabled(True)
  123. self.clientsTreeWidget.setAlternatingRowColors(True)
  124. #Signals
  125. self.clientsTreeWidget.currentItemChanged.connect(self._reactSignal_currentClientChanged)
  126. self.clientsTreeWidget.itemDoubleClicked.connect(self._reactSignal_itemDoubleClicked) #This is hide/show and NOT edit
  127. self.clientsTreeWidget.itemDelegate().closeEditor.connect(self._reactSignal_itemEditingFinished)
  128. self.clientsTreeWidget.model().layoutAboutToBeChanged.connect(self._reactSignal_rememberSorting)
  129. self.clientsTreeWidget.model().layoutChanged.connect(self._reactSignal_restoreSorting)
  130. #Convenience Signals to directly disable the client messages on gui instruction.
  131. #This is purely for speed and preventing the user from sending a signal while the session is shutting down
  132. self.mainWindow.ui.actionSessionAbort.triggered.connect(lambda: self._updateClientMenu(deactivate=True))
  133. self.mainWindow.ui.actionSessionSaveAndClose.triggered.connect(lambda: self._updateClientMenu(deactivate=True))
  134. #API Callbacks
  135. api.callbacks.sessionOpenLoading.append(self._cleanClients)
  136. api.callbacks.sessionOpenReady.append(self._updateClientMenu)
  137. api.callbacks.sessionClosed.append(lambda: self._updateClientMenu(deactivate=True))
  138. api.callbacks.clientStatusChanged.append(self._reactCallback_clientStatusChanged)
  139. api.callbacks.dataClientNamesChanged.append(self._reactCallback_dataClientNamesChanged)
  140. def _adjustColumnSize(self):
  141. self.clientsTreeWidget.sortByColumn(self.sortByColumn, self.sortAscending)
  142. for index in range(self.clientsTreeWidget.columnCount()):
  143. self.clientsTreeWidget.resizeColumnToContents(index)
  144. def _cleanClients(self, nsmSessionExportDict:dict):
  145. """Reset everything to the initial, empty state.
  146. We do not reset in in openReady because that signifies that the session is ready.
  147. And not in session closed because we want to setup data structures."""
  148. ClientItem.allItems.clear()
  149. self.clientsTreeWidget.clear()
  150. def clientsContextMenu(self, qpoint):
  151. """Reuses the menubar menus"""
  152. pos = QtGui.QCursor.pos()
  153. pos.setY(pos.y() + 5)
  154. item = self.clientsTreeWidget.itemAt(qpoint)
  155. if not type(item) is ClientItem:
  156. self.mainWindow.ui.menuSession.exec_(pos)
  157. return
  158. if not item is self.clientsTreeWidget.currentItem():
  159. #Some mouse combinations can lead to getting a different context menu than the clicked item.
  160. self.clientsTreeWidget.setCurrentItem(item)
  161. menu = self.mainWindow.ui.menuClientNameId
  162. menu.exec_(pos)
  163. def _startEditingName(self, *args):
  164. currentItem = self.clientsTreeWidget.currentItem()
  165. self.editableItem = currentItem
  166. column = self.clientsTreeWidgetColumns.index("reportedName")
  167. self.clientsTreeWidget.editItem(currentItem, column)
  168. def _reactSignal_itemEditingFinished(self, qLineEdit, returnCode):
  169. """This is a hacky signal. It arrives every change, programatically or manually.
  170. We therefore only connect this signal right after a double click and disconnect it
  171. afterwards.
  172. And we still need to block signals while this is running.
  173. returnCode: no clue? Integers all over the place...
  174. """
  175. treeWidgetItem = self.editableItem
  176. self.editableItem = None
  177. self.clientsTreeWidget.blockSignals(True)
  178. if treeWidgetItem:
  179. #We send the signal directly. Updating is done via callback.
  180. newName = treeWidgetItem.text(0)
  181. if not newName == treeWidgetItem.clientDict["reportedName"]:
  182. api.clientNameOverride(treeWidgetItem.clientDict["clientId"], newName)
  183. self.clientsTreeWidget.blockSignals(False)
  184. def _reactSignal_currentClientChanged(self, treeWidgetItem, previousItem):
  185. """Cache the current id for the client menu and shortcuts"""
  186. if treeWidgetItem:
  187. self.currentClientId = treeWidgetItem.clientDict["clientId"]
  188. else:
  189. self.currentClientId = None
  190. self._updateClientMenu()
  191. def _reactSignal_itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int):
  192. if item.clientDict["hasOptionalGUI"]:
  193. api.clientToggleVisible(item.clientDict["clientId"])
  194. def _reactCallback_clientStatusChanged(self, clientDict:dict):
  195. """The major client callback. Maps to nsmd status changes.
  196. We will create and delete client tableWidgetItems based on this
  197. """
  198. assert clientDict
  199. clientId = clientDict["clientId"]
  200. if clientId in ClientItem.allItems:
  201. if clientDict["lastStatus"] == "removed":
  202. index = self.clientsTreeWidget.indexOfTopLevelItem(ClientItem.allItems[clientId])
  203. self.clientsTreeWidget.takeTopLevelItem(index)
  204. del ClientItem.allItems[clientId]
  205. else:
  206. ClientItem.allItems[clientId].updateData(clientDict)
  207. self._updateClientMenu() #Update here is fine because shutdown sets to status removed.
  208. else:
  209. #Create new. Item will be parented by Qt, so Python GC will not delete
  210. item = ClientItem(parentController=self, clientDict=clientDict)
  211. self.clientsTreeWidget.addTopLevelItem(item)
  212. self._adjustColumnSize()
  213. #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.
  214. def _reactCallback_dataClientNamesChanged(self, clientOverrideNames:dict):
  215. """We either expect a dict or None. If None we return after clearing the data.
  216. We clear every callback and re-build.
  217. The dict can be content-empty of course."""
  218. logger.info(f"Received dataStorage names update: {clientOverrideNames}")
  219. #Clear current GUI data.
  220. for clientInstance in ClientItem.allItems.values():
  221. clientInstance.dataClientNameOverride(None)
  222. if clientOverrideNames is None: #This only happens if there was a client present and that exits.
  223. self.clientOverrideNamesCache = None
  224. else:
  225. #Real data
  226. #assert "origin" in data, data . Not in a fresh session, after adding!
  227. #assert data["origin"] == "https://www.laborejo.org/argodejo/nsm-data", data["origin"]
  228. self.clientOverrideNamesCache = clientOverrideNames #Can be empty dict as well
  229. clients = ClientItem.allItems
  230. for clientId, name in clientOverrideNames.items():
  231. #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.
  232. #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.
  233. if clientId in clients:
  234. clients[clientId].dataClientNameOverride(name)
  235. self._updateClientMenu() #Update because we need to en/disable the rename action
  236. self._adjustColumnSize()
  237. def _updateClientMenu(self, deactivate=False):
  238. """The client menu changes with every currentItem edit to reflect the name and capabilities"""
  239. ui = self.mainWindow.ui
  240. menu = ui.menuClientNameId
  241. if deactivate:
  242. currentItem = None
  243. else:
  244. currentItem = self.clientsTreeWidget.currentItem()
  245. if currentItem:
  246. clientId = currentItem.clientDict["clientId"]
  247. state = True
  248. #if currentItem.clientDict["label"]:
  249. # name = currentItem.clientDict["label"]
  250. #else:
  251. # name = currentItem.clientDict["reportedName"]
  252. name = currentItem.text(self.clientsTreeWidgetColumns.index("reportedName"))
  253. else:
  254. state = False
  255. name = "Client"
  256. menu.setTitle(name)
  257. #menu.setEnabled(state) #It is not enough to disable the menu itself. Shortcuts will still work.
  258. for action in menu.actions():
  259. action.setEnabled(state)
  260. if state:
  261. ui.actionClientRename.triggered.disconnect()
  262. ui.actionClientRename.triggered.connect(self._startEditingName)
  263. #ui.actionClientRename.triggered.connect(lambda: self.clientsTreeWidget.editItem(currentItem, self.clientsTreeWidgetColumns.index("reportedName")))
  264. ui.actionClientSave_separately.triggered.disconnect()
  265. ui.actionClientSave_separately.triggered.connect(lambda: api.clientSave(clientId))
  266. ui.actionClientStop.triggered.disconnect()
  267. ui.actionClientStop.triggered.connect(lambda: api.clientStop(clientId))
  268. #ui.actionClientStop.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback
  269. ui.actionClientResume.triggered.disconnect()
  270. ui.actionClientResume.triggered.connect(lambda: api.clientResume(clientId))
  271. #ui.actionClientResume.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback
  272. ui.actionClientRemove.triggered.disconnect()
  273. ui.actionClientRemove.triggered.connect(lambda: api.clientRemove(clientId))
  274. #Deactivate depending on the state of the program
  275. if currentItem.clientDict["lastStatus"] == "stopped":
  276. ui.actionClientSave_separately.setEnabled(False)
  277. ui.actionClientStop.setEnabled(False)
  278. ui.actionClientToggleVisible.setEnabled(False)
  279. else:
  280. #Hide and show shall only be enabled and connected if supported by the client
  281. try:
  282. ui.actionClientToggleVisible.triggered.disconnect()
  283. except TypeError: #TypeError: disconnect() failed between 'triggered' and all its connections
  284. pass
  285. if currentItem.clientDict["hasOptionalGUI"]:
  286. ui.actionClientToggleVisible.setEnabled(True)
  287. ui.actionClientToggleVisible.triggered.connect(lambda: api.clientToggleVisible(clientId))
  288. else:
  289. ui.actionClientToggleVisible.setEnabled(False)
  290. #Only rename when dataclient is present
  291. #None or dict, even empty dict
  292. if self.clientOverrideNamesCache is None:
  293. ui.actionClientRename.setEnabled(False)
  294. else:
  295. ui.actionClientRename.setEnabled(True)
  296. def _reactSignal_rememberSorting(self, *args):
  297. self.sortByColumn = self.clientsTreeWidget.header().sortIndicatorSection()
  298. self.sortDescending = self.clientsTreeWidget.header().sortIndicatorOrder()
  299. def _reactSignal_restoreSorting(self, *args):
  300. self.clientsTreeWidget.sortByColumn(self.sortByColumn, self.sortDescending)
  301. class LauncherProgram(QtWidgets.QTreeWidgetItem):
  302. """
  303. Example:
  304. { 'categories': 'AudioVideo;Audio;X-Recorders;X-Multitrack;X-Jack;',
  305. 'comment': 'Easy to use pattern sequencer for JACK and NSM',
  306. 'comment[de]': 'Einfach zu bedienender Pattern-Sequencer',
  307. 'exec': 'patroneo',
  308. 'genericname': 'Sequencer',
  309. 'icon': 'patroneo',
  310. 'name': 'Patroneo',
  311. 'startupnotify': 'false',
  312. 'terminal': 'false',
  313. 'type': 'Application',
  314. 'version': '1.0', #desktop spec version, not progra,
  315. 'x-nsm-capable': 'true'}
  316. """
  317. allItems = {} # clientId : ClientItem
  318. def __init__(self, parentController, launcherDict:dict):
  319. LauncherProgram.allItems[launcherDict["argodejoExec"]] = self
  320. self.parentController = parentController
  321. self.launcherDict = launcherDict
  322. self.executable = launcherDict["argodejoExec"]
  323. parameterList = [] #later in update
  324. super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type)
  325. self.updateData(launcherDict)
  326. def updateData(self, launcherDict:dict):
  327. """Arrives via parenTreeWidget api callback"""
  328. self.launcherDict = launcherDict
  329. for index, key in enumerate(self.parentController.columns):
  330. if (not key in launcherDict) or launcherDict[key] is None:
  331. t = ""
  332. else:
  333. t = str(launcherDict[key])
  334. self.setText(index, t)
  335. programIcons = self.parentController.mainWindow.programIcons
  336. assert programIcons
  337. if launcherDict["argodejoExec"] in programIcons:
  338. icon = programIcons[launcherDict["argodejoExec"]]
  339. self.setIcon(self.parentController.columns.index("name"), icon) #name is correct here. this is just the column.
  340. class LauncherTable(object):
  341. """Controls the QTreeWidget that holds programs in the PATH.
  342. """
  343. def __init__(self, mainWindow, parent):
  344. self.mainWindow = mainWindow
  345. self.parent = parent
  346. self.sortByColumn = 0 # by name
  347. self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
  348. self.launcherWidget = self.mainWindow.ui.loadedSessionsLauncher
  349. self.launcherWidget.setIconSize(iconSize)
  350. self.columns = ("name", "comment", "argodejoFullPath") #basically an enum
  351. self.headerLables = [
  352. QtCore.QCoreApplication.translate("Launcher", "Name"),
  353. QtCore.QCoreApplication.translate("Launcher", "Description"),
  354. QtCore.QCoreApplication.translate("Launcher", "Path"),
  355. ]
  356. self.launcherWidget.setHeaderLabels(self.headerLables)
  357. self.launcherWidget.setSortingEnabled(True)
  358. self.launcherWidget.setAlternatingRowColors(True)
  359. #The actual program entries are handled by the LauncherProgram item class
  360. self.buildPrograms()
  361. #Signals
  362. self.launcherWidget.itemDoubleClicked.connect(self._reactSignal_launcherItemDoubleClicked)
  363. def _adjustColumnSize(self):
  364. self.launcherWidget.sortByColumn(self.sortByColumn, self.sortAscending)
  365. for index in range(self.launcherWidget.columnCount()):
  366. self.launcherWidget.resizeColumnToContents(index)
  367. def _reactSignal_launcherItemDoubleClicked(self, item):
  368. api.clientAdd(item.executable)
  369. def buildPrograms(self):
  370. """Called by mainWindow.updateProgramDatabase
  371. Receive entries from the engine.
  372. Entry is a dict modelled after a .desktop file.
  373. But not all entries have all data. Some are barebones executable name and path.
  374. Only guaranteed keys are argodejoExec and argodejoFullPath, which in turn are files
  375. guaranteed to exist in the path.
  376. """
  377. self.launcherWidget.clear()
  378. programs = api.getSystemPrograms()
  379. for entry in programs:
  380. item = LauncherProgram(parentController=self, launcherDict=entry)
  381. self.launcherWidget.addTopLevelItem(item)
  382. self._adjustColumnSize()
  383. class OpenSessionController(object):
  384. """Not a subclass. Controls the visible tab, when a session is open.
  385. There is only one open instance at a time that controls the GUI and cleans itself."""
  386. def __init__(self, mainWindow):
  387. self.mainWindow = mainWindow
  388. self.clientTabe = ClientTable(mainWindow=mainWindow, parent=self)
  389. self.launcherTable = LauncherTable(mainWindow=mainWindow, parent=self)
  390. self.descriptionController = DescriptionController(mainWindow, self.mainWindow.ui.loadedSessionDescriptionGroupBox, self.mainWindow.ui.loadedSessionDescription)
  391. self.sessionLoadedPanel = mainWindow.ui.session_loaded #groupbox
  392. self.sessionProgramsPanel = mainWindow.ui.session_programs #groupbox
  393. #API Callbacks
  394. api.callbacks.sessionOpenReady.append(self._reactCallback_sessionOpen)
  395. logger.info("Full View Open Session Controller ready")
  396. def _reactCallback_sessionOpen(self, nsmSessionExportDict:dict):
  397. """Open does not mean we come from the session chooser. Switching does not close a session"""
  398. #self.description.clear() #Deletes the placesholder and text!
  399. self.sessionLoadedPanel.setTitle(nsmSessionExportDict["nsmSessionName"])