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