#! /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
import engine . findicons as findicons
#Qt
from . descriptiontextwidget import DescriptionController
from . helper import iconFromString
iconSize = QtCore . QSize ( 16 , 16 )
class ClientItem ( QtWidgets . QTreeWidgetItem ) :
"""
Item on the right side . Clients of the session , in various states .
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
" label " : None , #str
" lastStatus " : None , #str
" statusHistory " : [ ] , #list
" hasOptionalGUI " : False , #bool
" visible " : None , # bool
" dirty " : None , # bool
}
"""
allItems = { } # clientId : ClientItem
def __init__ ( self , parentController , clientDict : dict ) :
ClientItem . allItems [ clientDict [ " clientId " ] ] = self
self . parentController = parentController
self . clientDict = clientDict
parameterList = [ ] #later in update
super ( ) . __init__ ( parameterList , type = 1000 ) #type 0 is default qt type. 1000 is subclassed user type)
self . defaultFlags = self . flags ( )
self . setFlags ( self . defaultFlags | QtCore . Qt . ItemIsEditable ) #We have editTrigger to none so we can explicitly allow to only edit the name column on menuAction
#self.treeWidget() not ready at this point
self . updateData ( clientDict )
self . updateIcon ( clientDict )
def dataClientNameOverride ( self , name : str ) :
""" Either string or None. If None we reset to nsmd name """
logger . info ( f " Custom name for id { self . clientDict [ ' clientId ' ] } { self . clientDict [ ' reportedName ' ] } : { name } " )
if name :
text = name
else :
text = self . clientDict [ " reportedName " ]
index = self . parentController . clientsTreeWidgetColumns . index ( " reportedName " )
self . setText ( index , text )
def updateData ( self , clientDict : dict ) :
""" Arrives via parenTreeWidget api callback statusChanged, which is nsm status changed """
self . clientDict = clientDict
for index , key in enumerate ( self . parentController . clientsTreeWidgetColumns ) :
if clientDict [ key ] is None :
t = " "
else :
value = clientDict [ key ]
if key == " visible " :
if value == True :
t = " ✔ "
else :
t = " ✖ "
elif key == " dirty " :
if value == True :
t = QtCore . QCoreApplication . translate ( " OpenSession " , " not saved " )
else :
t = QtCore . QCoreApplication . translate ( " OpenSession " , " clean " )
elif key == " reportedName " and self . parentController . clientOverrideNamesCache :
if clientDict [ " clientId " ] in self . parentController . clientOverrideNamesCache :
t = self . parentController . clientOverrideNamesCache [ clientDict [ " clientId " ] ]
logger . info ( f " Update Data: custom name for id { self . clientDict [ ' clientId ' ] } { self . clientDict [ ' reportedName ' ] } : { t } " )
else :
t = str ( value )
else :
t = str ( value )
self . setText ( index , t )
nameColumn = self . parentController . clientsTreeWidgetColumns . index ( " reportedName " )
if clientDict [ " reportedName " ] is None :
self . setText ( nameColumn , clientDict [ " executable " ] )
def updateIcon ( self , clientDict : dict ) :
""" Just called during init """
programIcons = self . parentController . mainWindow . programIcons
assert programIcons
assert " executable " in clientDict , clientDict
iconColumn = self . parentController . clientsTreeWidgetColumns . index ( " reportedName " )
if clientDict [ " executable " ] in programIcons :
icon = programIcons [ clientDict [ " executable " ] ]
assert icon , icon
self . setIcon ( iconColumn , icon ) #reported name is correct here. this is just the column.
else : #Not NSM client added by the prompt widget
result = findicons . findIconPath ( clientDict [ " executable " ] )
if result :
icon = QtGui . QIcon . fromTheme ( str ( result [ 0 ] ) )
else :
icon = QtGui . QIcon . fromTheme ( clientDict [ " executable " ] )
if not icon . isNull ( ) :
self . setIcon ( iconColumn , icon )
else :
self . setIcon ( iconColumn , iconFromString ( clientDict [ " executable " ] ) )
class ClientTable ( object ) :
""" Controls the QTreeWidget that holds loaded clients """
def __init__ ( self , mainWindow , parent ) :
self . mainWindow = mainWindow
self . parent = parent
self . clientOverrideNamesCache = None #None or dict. Dict is never truly empty, it has at least empty categories.
self . sortByColumnValue = 0 #by name
self . sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
self . clientsTreeWidget = self . mainWindow . ui . loadedSessionClients
self . clientsTreeWidget . setContextMenuPolicy ( QtCore . Qt . CustomContextMenu )
self . clientsTreeWidget . customContextMenuRequested . connect ( self . clientsContextMenu )
self . clientsTreeWidget . setSelectionMode ( QtWidgets . QAbstractItemView . SingleSelection )
self . clientsTreeWidget . setSelectionBehavior ( QtWidgets . QAbstractItemView . SelectRows )
self . clientsTreeWidget . setIconSize ( iconSize )
self . clientsTreeWidget . setEditTriggers ( QtWidgets . QAbstractItemView . NoEditTriggers ) #We only allow explicit editing.
self . clientsTreeWidgetColumns = ( " reportedName " , " label " , " lastStatus " , " visible " , " dirty " , " clientId " ) #basically an enum
self . clientHeaderLabels = [
QtCore . QCoreApplication . translate ( " OpenSession " , " Name " ) ,
QtCore . QCoreApplication . translate ( " OpenSession " , " Label " ) ,
QtCore . QCoreApplication . translate ( " OpenSession " , " Status " ) ,
QtCore . QCoreApplication . translate ( " OpenSession " , " Visible " ) ,
QtCore . QCoreApplication . translate ( " OpenSession " , " Changes " ) ,
QtCore . QCoreApplication . translate ( " OpenSession " , " ID " ) ,
]
self . clientsTreeWidget . setHeaderLabels ( self . clientHeaderLabels )
self . clientsTreeWidget . setSortingEnabled ( True )
self . clientsTreeWidget . setAlternatingRowColors ( True )
#Signals
self . clientsTreeWidget . currentItemChanged . connect ( self . _reactSignal_currentClientChanged )
self . clientsTreeWidget . itemDoubleClicked . connect ( self . _reactSignal_itemDoubleClicked ) #This is hide/show and NOT edit
self . clientsTreeWidget . itemDelegate ( ) . closeEditor . connect ( self . _reactSignal_itemEditingFinished )
self . clientsTreeWidget . model ( ) . layoutAboutToBeChanged . connect ( self . _reactSignal_rememberSorting )
#self.clientsTreeWidget.model().layoutChanged.connect(self._reactSignal_restoreSorting)
#Convenience Signals to directly disable the client messages on gui instruction.
#This is purely for speed and preventing the user from sending a signal while the session is shutting down
self . mainWindow . ui . actionSessionAbort . triggered . connect ( lambda : self . _updateClientMenu ( deactivate = True ) )
self . mainWindow . ui . actionSessionSaveAndClose . triggered . connect ( lambda : self . _updateClientMenu ( deactivate = True ) )
#API Callbacks
api . callbacks . sessionOpenLoading . append ( self . _cleanClients )
api . callbacks . sessionOpenReady . append ( self . _updateClientMenu )
api . callbacks . sessionClosed . append ( lambda : self . _updateClientMenu ( deactivate = True ) )
api . callbacks . clientStatusChanged . append ( self . _reactCallback_clientStatusChanged )
api . callbacks . dataClientNamesChanged . append ( self . _reactCallback_dataClientNamesChanged )
def _adjustColumnSize ( self ) :
self . clientsTreeWidget . sortItems ( self . sortByColumnValue , self . sortDescendingValue )
for index in range ( self . clientsTreeWidget . columnCount ( ) ) :
self . clientsTreeWidget . resizeColumnToContents ( index )
def _cleanClients ( self , nsmSessionExportDict : dict ) :
""" Reset everything to the initial, empty state.
We do not reset in in openReady because that signifies that the session is ready .
And not in session closed because we want to setup data structures . """
ClientItem . allItems . clear ( )
self . clientsTreeWidget . clear ( )
def clientsContextMenu ( self , qpoint ) :
""" Reuses the menubar menus """
pos = QtGui . QCursor . pos ( )
pos . setY ( pos . y ( ) + 5 )
item = self . clientsTreeWidget . itemAt ( qpoint )
if not type ( item ) is ClientItem :
self . mainWindow . ui . menuSession . exec_ ( pos )
return
if not item is self . clientsTreeWidget . currentItem ( ) :
#Some mouse combinations can lead to getting a different context menu than the clicked item.
self . clientsTreeWidget . setCurrentItem ( item )
menu = self . mainWindow . ui . menuClientNameId
menu . exec_ ( pos )
def _startEditingName ( self , * args ) :
currentItem = self . clientsTreeWidget . currentItem ( )
self . editableItem = currentItem
column = self . clientsTreeWidgetColumns . index ( " reportedName " )
self . clientsTreeWidget . editItem ( currentItem , column )
def _reactSignal_itemEditingFinished ( self , qLineEdit , returnCode ) :
""" This is a hacky signal. It arrives every change, programatically or manually.
We therefore only connect this signal right after a double click and disconnect it
afterwards .
And we still need to block signals while this is running .
returnCode : no clue ? Integers all over the place . . .
"""
treeWidgetItem = self . editableItem
self . editableItem = None
self . clientsTreeWidget . blockSignals ( True )
if treeWidgetItem :
#We send the signal directly. Updating is done via callback.
newName = treeWidgetItem . text ( 0 )
if not newName == treeWidgetItem . clientDict [ " reportedName " ] :
api . clientNameOverride ( treeWidgetItem . clientDict [ " clientId " ] , newName )
self . clientsTreeWidget . blockSignals ( False )
def _reactSignal_currentClientChanged ( self , treeWidgetItem , previousItem ) :
""" Cache the current id for the client menu and shortcuts """
if treeWidgetItem :
self . currentClientId = treeWidgetItem . clientDict [ " clientId " ]
else :
self . currentClientId = None
self . _updateClientMenu ( )
def _reactSignal_itemDoubleClicked ( self , item : QtWidgets . QTreeWidgetItem , column : int ) :
if item . clientDict [ " hasOptionalGUI " ] :
api . clientToggleVisible ( item . clientDict [ " clientId " ] )
def _reactCallback_clientStatusChanged ( self , clientDict : dict ) :
""" The major client callback. Maps to nsmd status changes.
We will create and delete client tableWidgetItems based on this
"""
assert clientDict
clientId = clientDict [ " clientId " ]
if clientId in ClientItem . allItems :
if clientDict [ " lastStatus " ] == " removed " :
index = self . clientsTreeWidget . indexOfTopLevelItem ( ClientItem . allItems [ clientId ] )
self . clientsTreeWidget . takeTopLevelItem ( index )
del ClientItem . allItems [ clientId ]
else :
ClientItem . allItems [ clientId ] . updateData ( clientDict )
self . _updateClientMenu ( ) #Update here is fine because shutdown sets to status removed.
else :
#Create new. Item will be parented by Qt, so Python GC will not delete
item = ClientItem ( parentController = self , clientDict = clientDict )
self . clientsTreeWidget . addTopLevelItem ( item )
self . _adjustColumnSize ( )
#Do not put a general menuUpdate here. It will re-open the client menu during shutdown, enabling the user to send false commands to the client.
def _reactCallback_dataClientNamesChanged ( self , clientOverrideNames : dict ) :
""" We either expect a dict or None. If None we return after clearing the data.
We clear every callback and re - build .
The dict can be content - empty of course . """
logger . info ( f " Received dataStorage names update: { clientOverrideNames } " )
#Clear current GUI data.
for clientInstance in ClientItem . allItems . values ( ) :
clientInstance . dataClientNameOverride ( None )
if clientOverrideNames is None : #This only happens if there was a client present and that exits.
self . clientOverrideNamesCache = None
else :
#Real data
#assert "origin" in data, data . Not in a fresh session, after adding!
#assert data["origin"] == "https://www.laborejo.org/agordejo/nsm-data", data["origin"]
self . clientOverrideNamesCache = clientOverrideNames #Can be empty dict as well
clients = ClientItem . allItems
for clientId , name in clientOverrideNames . items ( ) :
#It is possible on session start, that a client has not yet loaded but we already receive a name override. nsm-data is instructed to only announce after session has loaded, but that can go wrong when nsmd has a bad day.
#Long story short: better to not rename right now, have some name mismatch and wait for a general update later, which will happen after every client load anyway.
if clientId in clients :
clients [ clientId ] . dataClientNameOverride ( name )
self . _updateClientMenu ( ) #Update because we need to en/disable the rename action
self . _adjustColumnSize ( )
def _updateClientMenu ( self , deactivate = False ) :
""" The client menu changes with every currentItem edit to reflect the name and capabilities """
ui = self . mainWindow . ui
menu = ui . menuClientNameId
if deactivate :
currentItem = None
else :
currentItem = self . clientsTreeWidget . currentItem ( )
if currentItem :
clientId = currentItem . clientDict [ " clientId " ]
state = True
#if currentItem.clientDict["label"]:
# name = currentItem.clientDict["label"]
#else:
# name = currentItem.clientDict["reportedName"]
name = currentItem . text ( self . clientsTreeWidgetColumns . index ( " reportedName " ) )
else :
state = False
name = " Client "
menu . setTitle ( name )
#menu.setEnabled(state) #It is not enough to disable the menu itself. Shortcuts will still work.
for action in menu . actions ( ) :
action . setEnabled ( state )
if state :
ui . actionClientRename . triggered . disconnect ( )
ui . actionClientRename . triggered . connect ( self . _startEditingName )
#ui.actionClientRename.triggered.connect(lambda: self.clientsTreeWidget.editItem(currentItem, self.clientsTreeWidgetColumns.index("reportedName")))
ui . actionClientSave_separately . triggered . disconnect ( )
ui . actionClientSave_separately . triggered . connect ( lambda : api . clientSave ( clientId ) )
ui . actionClientStop . triggered . disconnect ( )
ui . actionClientStop . triggered . connect ( lambda : api . clientStop ( clientId ) )
#ui.actionClientStop.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback
ui . actionClientResume . triggered . disconnect ( )
ui . actionClientResume . triggered . connect ( lambda : api . clientResume ( clientId ) )
#ui.actionClientResume.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback
ui . actionClientRemove . triggered . disconnect ( )
ui . actionClientRemove . triggered . connect ( lambda : api . clientRemove ( clientId ) )
#Deactivate depending on the state of the program
if currentItem . clientDict [ " lastStatus " ] == " stopped " :
ui . actionClientSave_separately . setEnabled ( False )
ui . actionClientStop . setEnabled ( False )
ui . actionClientToggleVisible . setEnabled ( False )
ui . actionClientResume . setEnabled ( True )
else :
ui . actionClientResume . setEnabled ( False )
#Hide and show shall only be enabled and connected if supported by the client
try :
ui . actionClientToggleVisible . triggered . disconnect ( )
except TypeError : #TypeError: disconnect() failed between 'triggered' and all its connections
pass
if currentItem . clientDict [ " hasOptionalGUI " ] :
ui . actionClientToggleVisible . setEnabled ( True )
ui . actionClientToggleVisible . triggered . connect ( lambda : api . clientToggleVisible ( clientId ) )
else :
ui . actionClientToggleVisible . setEnabled ( False )
#Only rename when dataclient is present
#None or dict, even empty dict
if self . clientOverrideNamesCache is None :
ui . actionClientRename . setEnabled ( False )
else :
ui . actionClientRename . setEnabled ( True )
def _reactSignal_rememberSorting ( self , * args ) :
self . sortByColumnValue = self . clientsTreeWidget . header ( ) . sortIndicatorSection ( )
self . sortDescendingValue = self . clientsTreeWidget . header ( ) . sortIndicatorOrder ( )
def _reactSignal_restoreSorting ( self , * args ) :
""" Do not use as signal!!! Will lead to infinite recursion since Qt 5.12.2 """
#self.clientsTreeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue)
raise RuntimeError ( )
class LauncherProgram ( QtWidgets . QTreeWidgetItem ) :
"""
An item on the left side of the window . Used to start programs and show info , but nothing more .
Example :
{ ' categories ' : ' AudioVideo;Audio;X-Recorders;X-Multitrack;X-Jack; ' ,
' comment ' : ' Easy to use pattern sequencer for JACK and NSM ' ,
' comment[de] ' : ' Einfach zu bedienender Pattern-Sequencer ' ,
' exec ' : ' patroneo ' ,
' genericname ' : ' Sequencer ' ,
' icon ' : ' patroneo ' ,
' name ' : ' Patroneo ' ,
' startupnotify ' : ' false ' ,
' terminal ' : ' false ' ,
' type ' : ' Application ' ,
' version ' : ' 1.0 ' , #desktop spec version, not progra,
' x-nsm-capable ' : ' true ' }
Also :
' agordejoExec ' : the actual nsm exe
' agordejoIconPath ' : a priority path the engine found for us
"""
allItems = { } # clientId : ClientItem
def __init__ ( self , parentController , launcherDict : dict ) :
LauncherProgram . allItems [ launcherDict [ " agordejoExec " ] ] = self
self . parentController = parentController
self . launcherDict = launcherDict
self . executable = launcherDict [ " agordejoExec " ]
parameterList = [ ] #later in update
super ( ) . __init__ ( parameterList , type = 1000 ) #type 0 is default qt type. 1000 is subclassed user type)
self . updateData ( launcherDict )
def updateData ( self , launcherDict : dict ) :
""" Arrives via parenTreeWidget api callback """
self . launcherDict = launcherDict
for index , key in enumerate ( self . parentController . columns ) :
if ( not key in launcherDict ) or launcherDict [ key ] is None :
t = " "
else :
t = str ( launcherDict [ key ] )
self . setText ( index , t )
programIcons = self . parentController . mainWindow . programIcons
assert programIcons
if launcherDict [ " agordejoExec " ] in programIcons :
icon = programIcons [ launcherDict [ " agordejoExec " ] ]
self . setIcon ( self . parentController . columns . index ( " name " ) , icon ) #name is correct here. this is just the column.
class LauncherTable ( object ) :
""" Controls the QTreeWidget that holds programs in the PATH.
"""
def __init__ ( self , mainWindow , parent ) :
self . mainWindow = mainWindow
self . parent = parent
self . sortByColumnValue = 0 # by name
self . sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
self . launcherWidget = self . mainWindow . ui . loadedSessionsLauncher
self . launcherWidget . setIconSize ( iconSize )
self . columns = ( " name " , " comment " , " agordejoFullPath " ) #basically an enum
self . headerLables = [
QtCore . QCoreApplication . translate ( " Launcher " , " Name " ) ,
QtCore . QCoreApplication . translate ( " Launcher " , " Description " ) ,
QtCore . QCoreApplication . translate ( " Launcher " , " Path " ) ,
]
self . launcherWidget . setHeaderLabels ( self . headerLables )
self . launcherWidget . setSortingEnabled ( True )
self . launcherWidget . setAlternatingRowColors ( True )
self . launcherWidget . setSelectionMode ( QtWidgets . QAbstractItemView . SingleSelection )
self . launcherWidget . setSelectionBehavior ( QtWidgets . QAbstractItemView . SelectRows )
##The actual program entries are handled by the LauncherProgram item class
#self.buildPrograms() #Don't call here. MainWindow calls it when everything is ready.
#Signals
self . launcherWidget . itemDoubleClicked . connect ( self . _reactSignal_launcherItemDoubleClicked )
def _adjustColumnSize ( self ) :
self . launcherWidget . sortItems ( self . sortByColumnValue , self . sortDescendingValue )
for index in range ( self . launcherWidget . columnCount ( ) ) :
self . launcherWidget . resizeColumnToContents ( index )
def _reactSignal_launcherItemDoubleClicked ( self , item ) :
api . clientAdd ( item . executable )
def buildPrograms ( self ) :
""" Called by mainWindow.updateProgramDatabase
Receive entries from the engine .
Entry is a dict modelled after a . desktop file .
But not all entries have all data . Some are barebones executable name and path .
Only guaranteed keys are agordejoExec and agordejoFullPath , which in turn are files
guaranteed to exist in the path .
"""
self . launcherWidget . clear ( )
engineCache = api . getCache ( )
programs = engineCache [ " programs " ]
for entry in programs :
item = LauncherProgram ( parentController = self , launcherDict = entry )
self . launcherWidget . addTopLevelItem ( item )
self . _adjustColumnSize ( )
class OpenSessionController ( object ) :
""" Not a subclass. Controls the visible tab, when a session is open.
There is only one open instance at a time that controls the GUI and cleans itself . """
def __init__ ( self , mainWindow ) :
self . mainWindow = mainWindow
self . clientTabe = ClientTable ( mainWindow = mainWindow , parent = self )
self . launcherTable = LauncherTable ( mainWindow = mainWindow , parent = self )
self . descriptionController = DescriptionController ( mainWindow , self . mainWindow . ui . loadedSessionDescriptionGroupBox , self . mainWindow . ui . loadedSessionDescription )
self . sessionLoadedPanel = mainWindow . ui . session_loaded #groupbox
self . sessionProgramsPanel = mainWindow . ui . session_programs #groupbox
#API Callbacks
api . callbacks . sessionOpenReady . append ( self . _reactCallback_sessionOpen )
logger . info ( " Full View Open Session Controller ready " )
def _reactCallback_sessionOpen ( self , nsmSessionExportDict : dict ) :
""" Open does not mean we come from the session chooser. Switching does not close a session """
#self.description.clear() #Deletes the placesholder and text!
self . sessionLoadedPanel . setTitle ( nsmSessionExportDict [ " nsmSessionName " ] )