#! /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 . """ 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.")