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.
332 lines
15 KiB
332 lines
15 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2020, 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
|
|
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed.
|
|
|
|
#QtGui
|
|
from .descriptiontextwidget import DescriptionController
|
|
from .resources import *
|
|
|
|
|
|
def nothing():
|
|
pass
|
|
|
|
class StarterClientItem(QtWidgets.QListWidgetItem):
|
|
""".desktop-like entry:
|
|
{'type': 'Application',
|
|
'name': 'Vico',
|
|
'genericname': 'Sequencer',
|
|
'comment':'Minimalistic midi sequencer with piano roll for JACK and NSM',
|
|
'exec': 'vico',
|
|
'icon': 'vico',
|
|
'terminal': 'false',
|
|
'startupnotify': 'false',
|
|
'categories': 'AudioVideo;Audio;X-Recorders;X-Multitrack;X-Jack;',
|
|
'x-nsm-capable': 'true', 'version': '1.0.1',
|
|
'agordejoFullPath': '/usr/bin/vico',
|
|
'agordejoExec': 'vico',
|
|
'whitelist': True}
|
|
|
|
This is the icon that is starter and status-indicator at once.
|
|
QuickSession has only one icon per agordejoExec.
|
|
|
|
If at least one program is running as nsmClient in the session we switch ourselves on and
|
|
save the status as self.nsmClientDict
|
|
|
|
We do not react to name overrides by nsm-data, nor do we react to labels or name changes
|
|
through reportedNames.
|
|
"""
|
|
allItems = {} #agordejoExec:StarterClientItem
|
|
|
|
def __init__(self, parentController, desktopEntry:dict):
|
|
self.parentController = parentController
|
|
self.desktopEntry = desktopEntry
|
|
self.agordejoExec = desktopEntry["agordejoExec"]
|
|
super().__init__(desktopEntry["name"], type=1000) #type 0 is default qt type. 1000 is subclassed user type)
|
|
|
|
self.nsmClientDict = None #aka nsmStatusDict if this exists it means at least one instance of this application is running in the session
|
|
|
|
if "comment" in desktopEntry:
|
|
self.setToolTip(desktopEntry["comment"])
|
|
else:
|
|
self.setToolTip(desktopEntry["name"])
|
|
programIcons = self.parentController.mainWindow.programIcons
|
|
assert programIcons
|
|
assert "agordejoExec" in desktopEntry, desktopEntry
|
|
if desktopEntry["agordejoExec"] in programIcons:
|
|
icon = programIcons[desktopEntry["agordejoExec"]]
|
|
self.setIcon(icon)
|
|
self.updateStatus(None) #removed/off
|
|
|
|
|
|
def updateStatus(self, clientDict:dict):
|
|
"""
|
|
api callback
|
|
|
|
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
|
|
"executable":None, #For dumb clients this is the same as reportedName.
|
|
"label":None, #str
|
|
"lastStatus":None, #str
|
|
"statusHistory":[], #list
|
|
"hasOptionalGUI": False, #bool
|
|
"visible": None, # bool
|
|
"dirty": None, # bool
|
|
}
|
|
"""
|
|
self.nsmClientDict = clientDict #for comparison with later status changes. Especially for stopped clients.
|
|
if clientDict is None:
|
|
self.removed()
|
|
else:
|
|
getattr(self, clientDict["lastStatus"], nothing)()
|
|
|
|
def _setIconOverlay(self, status:str):
|
|
options = {
|
|
"removed": ":alert.svg",
|
|
"stopped": ":power.svg",
|
|
"hidden": ":hidden.svg",
|
|
"ready": ":running.svg",
|
|
}
|
|
|
|
if status in options:
|
|
overlayPixmap = QtGui.QIcon(options[status]).pixmap(QtCore.QSize(30,30))
|
|
shadow = QtGui.QIcon(options[status]).pixmap(QtCore.QSize(35,35)) #original color is black
|
|
|
|
#Colorize overlay symbol. Painter works inplace.
|
|
painter = QtGui.QPainter(overlayPixmap);
|
|
painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceIn)
|
|
painter.fillRect(overlayPixmap.rect(), QtGui.QColor("cyan"))
|
|
painter.end()
|
|
|
|
icon = self.parentController.mainWindow.programIcons[self.agordejoExec]
|
|
pixmap = icon.pixmap(QtCore.QSize(70,70))
|
|
|
|
p = QtGui.QPainter(pixmap)
|
|
|
|
p.drawPixmap(0, -1, shadow)
|
|
p.drawPixmap(2, 2, overlayPixmap) #top left corner of icon, with some padding for the shadow
|
|
p.end()
|
|
ico = QtGui.QIcon(pixmap)
|
|
self.setIcon(ico)
|
|
else:
|
|
if self.agordejoExec in self.parentController.mainWindow.programIcons: #there was a strange bug once where this happened exactly one, and then everything was fine, including this icon. Some DB backwards compatibility.
|
|
ico = self.parentController.mainWindow.programIcons[self.agordejoExec]
|
|
self.setIcon(ico)
|
|
|
|
|
|
#Status
|
|
def ready(self):
|
|
if self.nsmClientDict["hasOptionalGUI"]:
|
|
if self.nsmClientDict["visible"]:
|
|
self._setIconOverlay("ready")
|
|
else:
|
|
self._setIconOverlay("hidden")
|
|
else:
|
|
self._setIconOverlay("ready")
|
|
|
|
#self.setFlags(QtCore.Qt.ItemIsEnabled)
|
|
|
|
def removed(self):
|
|
#self.setFlags(QtCore.Qt.NoItemFlags) #Black and white. We can still mouseClick through parent signal when set to NoItemFlags
|
|
self.nsmClientDict = None #in opposite to stop
|
|
|
|
def stopped(self):
|
|
self.setFlags(QtCore.Qt.ItemIsEnabled)
|
|
self._setIconOverlay("stopped")
|
|
|
|
def handleClick(self):
|
|
alreadyInSession = api.executableInSession(self.agordejoExec)
|
|
#Paranoia Start
|
|
if self.nsmClientDict is None and alreadyInSession:
|
|
#Caught double-click. do nothing, this is a user-accident
|
|
return
|
|
elif self.nsmClientDict:
|
|
assert alreadyInSession
|
|
elif alreadyInSession:
|
|
assert self.nsmClientDict
|
|
#Paranoia End
|
|
|
|
if not alreadyInSession:
|
|
api.clientAdd(self.agordejoExec) #triggers status update callback which activates our item.
|
|
elif self.nsmClientDict["lastStatus"] == "stopped":
|
|
api.clientResume(self.nsmClientDict["clientId"])
|
|
else:
|
|
api.clientToggleVisible(self.nsmClientDict["clientId"]) #api is tolerant to sending this to non-optional-GUI clients
|
|
|
|
|
|
class QuickOpenSessionController(object):
|
|
"""Controls the widget, but does not subclass.
|
|
|
|
We want the simplest form of interaction possible: single touch.
|
|
No selections, no right click. Like a smartphone app.
|
|
"""
|
|
|
|
def __init__(self, mainWindow):
|
|
iconSize = 70
|
|
self.mainWindow = mainWindow
|
|
self.listWidget = mainWindow.ui.quickSessionClientsListWidget
|
|
self.listWidget.setIconSize(QtCore.QSize(iconSize,iconSize))
|
|
self.listWidget.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
|
|
self.listWidget.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
|
|
self.listWidget.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) #Icons can't be selected. Text still can
|
|
self.listWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
|
self.listWidget.setResizeMode(QtWidgets.QListView.Adjust)
|
|
self.listWidget.setGridSize(QtCore.QSize(iconSize*1.2,iconSize*2)) #x spacing, y for text
|
|
self.listWidget.setWordWrap(True) #needed for grid, don't use without grid (i.e. setSparcing and setUniformItemSizes)
|
|
#self.listWidget.setSpacing(20) # Grid is better
|
|
#self.listWidget.setUniformItemSizes(True) # Grid is better
|
|
|
|
|
|
self._nsmSessionExportDict = None
|
|
self.nameWidget = mainWindow.ui.quickSessionNameLineEdit
|
|
self.layout = mainWindow.ui.page_quickSessionLoaded.layout()
|
|
self.clientOverrideNamesCache = None #None or dict. Dict is never truly empty, it has at least empty categories.
|
|
self.descriptionController = DescriptionController(mainWindow, self.mainWindow.ui.quickSessionNotesGroupBox, self.mainWindow.ui.quickSessionNotesPlainTextEdit)
|
|
|
|
font = self.nameWidget.font()
|
|
font.setPixelSize(font.pixelSize() * 1.4)
|
|
self.nameWidget.setFont(font)
|
|
|
|
#GUI Signals
|
|
#self.listWidget.itemActivated.connect(lambda item:print (item)) #Activated is system dependend. On osx it might be single clicke, here on linux is double click. on phones it might be something else.
|
|
self.listWidget.itemClicked.connect(self._itemClicked)
|
|
|
|
mainWindow.ui.quickCloseOpenSession.clicked.connect(api.sessionClose)
|
|
font = mainWindow.ui.quickCloseOpenSession.font()
|
|
font.setPixelSize(font.pixelSize() * 1.2)
|
|
mainWindow.ui.quickCloseOpenSession.setFont(font)
|
|
|
|
mainWindow.ui.quickSaveOpenSession.clicked.connect(api.sessionSave)
|
|
mainWindow.ui.quickSaveOpenSession.hide()
|
|
|
|
#API Callbacks
|
|
api.callbacks.sessionOpenLoading.append(self.buildCleanStarterClients)
|
|
api.callbacks.sessionOpenLoading.append(self._openLoading)
|
|
api.callbacks.sessionOpenReady.append(self._openReady)
|
|
api.callbacks.sessionClosed.append(self._sendNameChange)
|
|
api.callbacks.clientStatusChanged.append(self._clientStatusChanged)
|
|
|
|
self.listWidget.setFocus() #take focus away from title-edit
|
|
|
|
logger.info("Quick Open Session Controller ready")
|
|
|
|
def _itemClicked(self, item):
|
|
self.listWidget.reset() #Hackity Hack! This is intended to revert the text-selection of items. However, that is not a real selection. clearSelection does nothing! Now it looks like a brief flash.
|
|
item.handleClick()
|
|
|
|
|
|
|
|
def _openLoading(self, nsmSessionExportDict):
|
|
self._nsmSessionExportDict = nsmSessionExportDict
|
|
self.nameWidget.setText(nsmSessionExportDict["nsmSessionName"])
|
|
|
|
def _openReady(self, nsmSessionExportDict):
|
|
self._nsmSessionExportDict = nsmSessionExportDict
|
|
self.nameWidget.setText(nsmSessionExportDict["nsmSessionName"])
|
|
|
|
def _sendNameChange(self):
|
|
"""The closed callback is send on start to indicate "no open session". exportDict cache is
|
|
not ready then. We need to test.
|
|
It is not possible to rename a running session. We allow the user to fake-edit the name
|
|
but will only send the api request after the session is closed"""
|
|
if not self._nsmSessionExportDict: #see docstring
|
|
return
|
|
|
|
if self.nameWidget.text() and not self.nameWidget.text() == self._nsmSessionExportDict["nsmSessionName"]:
|
|
logger.info(f"Instructing the api to rename session {self._nsmSessionExportDict['nsmSessionName']} to {self.nameWidget.text()} on close")
|
|
api.sessionRename(self._nsmSessionExportDict["nsmSessionName"], self.nameWidget.text())
|
|
|
|
self._nsmSessionExportDict = None #now really closed
|
|
|
|
def buildCleanStarterClients(self, nsmSessionExportDict:dict):
|
|
"""Reset everything to the initial, empty state.
|
|
We do not reset in openReady because that signifies that the session is ready.
|
|
And not in session closed because we want to setup data structures.
|
|
|
|
In comparison with the detailed view open session controller we need to do incremental
|
|
updates. The detailed view can just delete and recreater its launchers after a DB-update,
|
|
but we combine both views. So we can't just delete-and-rebuild because that destroys
|
|
running client states.
|
|
"""
|
|
engineCache = api.getCache()
|
|
programs = engineCache["programs"]
|
|
|
|
whitelist = [e for e in programs if e["whitelist"]]
|
|
leftovers = set(StarterClientItem.allItems.keys()) #"agordejoExec"
|
|
|
|
notForQuickView = ("nsm-data", "jackpatch", "nsm-proxy", "non-midi-mapper", "non-mixer-noui", "ray-proxy", "ray-jackpatch", "carla-jack-single", "carla-jack-multi")
|
|
|
|
for forIcon in StarterClientItem.allItems.values():
|
|
forIcon._setIconOverlay("") #empty initial state
|
|
|
|
for entry in whitelist:
|
|
exe = entry["agordejoExec"]
|
|
if exe in StarterClientItem.allItems:
|
|
if entry["agordejoExec"] in leftovers: #It happened that it was not. Don't ask me...
|
|
leftovers.remove(entry["agordejoExec"])
|
|
else:
|
|
#Create new. Item will be parented by Qt, so Python GC will not delete
|
|
if not exe in notForQuickView:
|
|
item = StarterClientItem(parentController=self, desktopEntry=entry)
|
|
self.listWidget.addItem(item)
|
|
StarterClientItem.allItems[entry["agordejoExec"]] = item
|
|
|
|
#Remove starters that were available until they got removed in the last db update
|
|
for loexe in leftovers:
|
|
item = StarterClientItem.allItems[loexe]
|
|
del StarterClientItem.allItems[loexe]
|
|
index = self.listWidget.indexFromItem(item).row() #Row is the real index in a listView, no matter iconViewMode.
|
|
self.listWidget.takeItem(index)
|
|
del item
|
|
|
|
|
|
def _clientStatusChanged(self, clientDict:dict):
|
|
"""Maps to nsmd status changes.
|
|
We already have icons for all programs, in opposite to detailed-view opensession controller.
|
|
Status updates are used to switch them on an off.
|
|
We also present only one icon per executable. If you want more go into the other mode.
|
|
"""
|
|
#index = self.listWidget.indexFromItem(QuickClientItem.allItems[clientId]).row() #Row is the real index in a listView, no matter iconViewMode.
|
|
|
|
assert clientDict["executable"]
|
|
|
|
if clientDict["dumbClient"]: #only real nsm clients in our session, whic includes the initial "not-yet" status of nsm-clients.
|
|
return
|
|
backgroundClients = METADATA["preferredClients"].values()
|
|
if clientDict["executable"] in backgroundClients:
|
|
return
|
|
|
|
if clientDict["executable"] in StarterClientItem.allItems:
|
|
item = StarterClientItem.allItems[clientDict["executable"]]
|
|
item.updateStatus(clientDict)
|
|
else:
|
|
logging.warning(f"Got client status update for {clientDict['executable']}, which is not in our database. This can happen if you install a program and do not update the DB. Please do so and then restart the session.")
|
|
|