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.
 
 

334 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',
'argodejoFullPath': '/usr/bin/vico',
'argodejoExec': 'vico',
'whitelist': True}
This is the icon that is starter and status-indicator at once.
QuickSession has only one icon per argodejoExec.
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 = {} #argodejoExec:StarterClientItem
def __init__(self, parentController, desktopEntry:dict):
self.parentController = parentController
self.desktopEntry = desktopEntry
self.argodejoExec = desktopEntry["argodejoExec"]
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 "argodejoExec" in desktopEntry, desktopEntry
if desktopEntry["argodejoExec"] in programIcons:
icon = programIcons[desktopEntry["argodejoExec"]]
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.argodejoExec]
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.argodejoExec 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.argodejoExec]
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.argodejoExec)
#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.argodejoExec) #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
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.
"""
whitelist = [e for e in api.getSystemPrograms() if e["whitelist"]]
leftovers = set(StarterClientItem.allItems.keys()) #"argodejoExec"
for forIcon in StarterClientItem.allItems.values():
forIcon._setIconOverlay("") #empty initial state
for entry in whitelist:
exe = entry["argodejoExec"]
if exe in StarterClientItem.allItems:
if entry["argodejoExec"] in leftovers: #It happened that it was not. Don't ask me...
leftovers.remove(entry["argodejoExec"])
else:
#Create new. Item will be parented by Qt, so Python GC will not delete
item = StarterClientItem(parentController=self, desktopEntry=entry)
self.listWidget.addItem(item)
StarterClientItem.allItems[entry["argodejoExec"]] = item
#Remove icons 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
#old: rebuild from scratch
"""
self.listWidget.clear()
StarterClientItem.allItems.clear()
whitelist = [e for e in api.getSystemPrograms() if e["whitelist"]]
for entry in whitelist:
#Create new. Item will be parented by Qt, so Python GC will not delete
item = StarterClientItem(parentController=self, desktopEntry=entry)
self.listWidget.addItem(item)
StarterClientItem.allItems[entry["argodejoExec"]] = 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.
if clientDict["dumbClient"]:
#This includes the initial loading 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.")