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.

333 lines
15 KiB

5 years ago
#! /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
5 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
5 years ago
#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 *
5 years ago
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',
5 years ago
'whitelist': True}
This is the icon that is starter and status-indicator at once.
QuickSession has only one icon per agordejoExec.
5 years ago
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
5 years ago
def __init__(self, parentController, desktopEntry:dict):
self.parentController = parentController
self.desktopEntry = desktopEntry
self.agordejoExec = desktopEntry["agordejoExec"]
5 years ago
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"]]
5 years ago
self.setIcon(icon)
self.updateStatus(None) #removed/off
5 years ago
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:
5 years ago
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)
5 years ago
#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)
5 years ago
def removed(self):
#self.setFlags(QtCore.Qt.NoItemFlags) #Black and white. We can still mouseClick through parent signal when set to NoItemFlags
5 years ago
self.nsmClientDict = None #in opposite to stop
def stopped(self):
self.setFlags(QtCore.Qt.ItemIsEnabled)
self._setIconOverlay("stopped")
5 years ago
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:
5 years ago
assert self.nsmClientDict
#Paranoia End
5 years ago
if not alreadyInSession:
api.clientAdd(self.agordejoExec) #triggers status update callback which activates our item.
5 years ago
elif self.nsmClientDict["lastStatus"] == "stopped":
api.clientResume(self.nsmClientDict["clientId"])
5 years ago
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"])
5 years ago
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"
4 years ago
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
5 years ago
for entry in whitelist:
exe = entry["agordejoExec"]
5 years ago
if exe in StarterClientItem.allItems:
if entry["agordejoExec"] in leftovers: #It happened that it was not. Don't ask me...
leftovers.remove(entry["agordejoExec"])
5 years ago
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
5 years ago
#Remove starters that were available until they got removed in the last db update
5 years ago
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
5 years ago
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.
5 years ago
return
backgroundClients = METADATA["preferredClients"].values()
if clientDict["executable"] in backgroundClients:
return
5 years ago
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.")