Nils
4 years ago
7 changed files with 13 additions and 753 deletions
@ -1,332 +0,0 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2021, 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.") |
@ -1,127 +0,0 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2021, 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 |
|||
import datetime |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
#Engine |
|||
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. |
|||
import engine.api as api |
|||
|
|||
|
|||
|
|||
class SessionButton(QtWidgets.QPushButton): |
|||
|
|||
def __init__(self, sessionDict): |
|||
self.sessionDict = sessionDict |
|||
super().__init__(sessionDict["nsmSessionName"]) |
|||
self.clicked.connect(self.openSession) |
|||
#self.setFlat(True) |
|||
|
|||
font = self.font() |
|||
font.setPixelSize(font.pixelSize() * 1.2 ) |
|||
self.setFont(font) |
|||
|
|||
#width = self.fontMetrics().boundingRect(sessionDict["nsmSessionName"]).width()+10 |
|||
#width = self.fontMetrics().boundingRect(longestSessionName).width()+10 |
|||
#width = parent.geometry().width() |
|||
#self.setFixedSize(width, 40) |
|||
self.setFixedHeight(40) |
|||
|
|||
|
|||
def openSession(self): |
|||
name = self.sessionDict["nsmSessionName"] |
|||
api.sessionOpen(name) |
|||
|
|||
class QuickSessionController(object): |
|||
"""Controls the widget, but does not subclass""" |
|||
|
|||
def __init__(self, mainWindow): |
|||
self.mainWindow = mainWindow |
|||
self.layout = mainWindow.ui.quickSessionChooser.layout() |
|||
#self.layout.setAlignment(QtCore.Qt.AlignHCenter) |
|||
newSessionButton = mainWindow.ui.quickNewSession |
|||
|
|||
font = newSessionButton.font() |
|||
font.setPixelSize(font.pixelSize() * 1.4) |
|||
newSessionButton.setFont(font) |
|||
|
|||
newSessionButton.setFixedHeight(40) |
|||
newSessionButton.setFocus(True) #Enter on program start creates a new session. |
|||
newSessionButton.clicked.connect(self._newTimestampSession) |
|||
api.callbacks.sessionsChanged.append(self._reactCallback_sessionsChanged) |
|||
|
|||
#self.layout.geometry().width() #very small |
|||
#self.mainWindow.ui.quickSessionChooser.geometry().width() #too small |
|||
#self.mainWindow.ui.scrollArea.geometry().width() |
|||
#mainWindow.geometry().width() |
|||
|
|||
|
|||
logger.info("Quick Session Chooser ready") |
|||
|
|||
def _clear(self): |
|||
"""Clear everything but the spacer item""" |
|||
for child in self.mainWindow.ui.quickSessionChooser.children(): |
|||
if type(child) is SessionButton: |
|||
self.layout.removeWidget(child) |
|||
child.setParent(None) |
|||
del child |
|||
|
|||
|
|||
|
|||
def _reactCallback_sessionsChanged(self, sessionDicts:list): |
|||
"""Main callback for new, added, removed, moved sessions etc.""" |
|||
logger.info("Rebuilding session buttons") |
|||
self._clear() #except the space |
|||
|
|||
spacer = self.layout.takeAt(0) |
|||
|
|||
#longestSessionName = "" |
|||
#for sessionDict in sessionDicts: |
|||
# if len(sessionDict["nsmSessionName"]) > len(longestSessionName): |
|||
# longestSessionName = sessionDict["nsmSessionName"] |
|||
|
|||
for sessionDict in sorted(sessionDicts, key=lambda d: d["nsmSessionName"]): |
|||
self.layout.addWidget(SessionButton(sessionDict)) |
|||
|
|||
#Finally add vertical spacer |
|||
#spacerItem = QtWidgets.QSpacerItem(1, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) #int w, int h, QSizePolicy::Policy hPolicy = QSizePolicy::Minimum, QSizePolicy::Policy vPolicy = QSizePolicy::Minimum |
|||
self.layout.addItem(spacer) |
|||
|
|||
def _newTimestampSession(self): |
|||
nsmExecutables = api.getNsmExecutables() #type set, cached, very fast. |
|||
con = METADATA["preferredClients"]["data"] |
|||
data = METADATA["preferredClients"]["connections"] |
|||
startclients = [] |
|||
if con in nsmExecutables: |
|||
startclients.append(con) |
|||
if data in nsmExecutables: |
|||
startclients.append(data) |
|||
|
|||
#now = datetime.datetime.now().replace(second=0, microsecond=0).isoformat()[:-3] |
|||
now = datetime.datetime.now().replace(microsecond=0).isoformat() |
|||
name = now |
|||
api.sessionNew(name, startclients) |
Loading…
Reference in new issue