#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2022, 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 #Our Qt from template.qtgui.helper import ToggleSwitch,FancySwitch from template.qtgui.submenus import nestedJackPortsMenu #Engine import engine.api as api COLUMNS = ("state", "idKey", "mixSend", "name", "loaded", "group", "tags" ) #Loaded = Variant class InstrumentTreeController(object): """ Shows the list of instruments, so they can be clicked upon :) Not a qt class. We externally controls the QTreeWidget Why is this not a QTableWidget? As in Agordejo, a TableWidget is a complex item, and inconvenient to use. You need to add an Item to each cell. While in TreeWidget you just create one item. """ def __init__(self, parentMainWindow, treeWidget): self.parentMainWindow = parentMainWindow self.treeWidget = treeWidget self.reset() #Includes: #self._cachedData = None #self._cachedLastInstrumentStatus = {} # instrument idKey : status Dict #self.guiLibraries = {} # idKey : GuiLibrary. idKey is a tuple with second value -1, which would be the instrument. #self.guiInstruments = {} # idKey : GuiInstrument self.headerLabels = [ QtCore.QCoreApplication.translate("InstrumentTreeController", "Enable"), QtCore.QCoreApplication.translate("InstrumentTreeController", "ID"), QtCore.QCoreApplication.translate("InstrumentTreeController", "toMix"), QtCore.QCoreApplication.translate("InstrumentTreeController", "Name"), QtCore.QCoreApplication.translate("InstrumentTreeController", "Loaded"), QtCore.QCoreApplication.translate("InstrumentTreeController", "Group"), QtCore.QCoreApplication.translate("InstrumentTreeController", "Tags"), ] self.treeWidget.setColumnCount(len(self.headerLabels)) self.treeWidget.setHeaderLabels(self.headerLabels) self.treeWidget.setSortingEnabled(True) self.treeWidget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.treeWidget.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.treeWidget.setAlternatingRowColors(True) self.treeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.treeWidget.header().setSortIndicator(0,0) self.treeWidget.itemDoubleClicked.connect(self.itemDoubleClicked) self.treeWidget.currentItemChanged.connect(self.parentMainWindow.currentTreeItemChanged) #self.treeWidget.itemSelectionChanged.connect(self.itemSelectionChanged) #also triggers on tab change between favorits and instruments #self.treeWidget.itemClicked.connect(self.itemSelectionChanged) #This will not activate when using the arrow keys to select self.treeWidget.itemExpanded.connect(self.itemExpandedOrCollapsed) self.treeWidget.itemCollapsed.connect(self.itemExpandedOrCollapsed) self.treeWidget.customContextMenuRequested.connect(self.contextMenu) self.sortByColumnValue = 1 #by instrId self.sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending api.callbacks.instrumentListMetadata.append(self.buildTree) #called without arguments it will just create the standard tree. This will only happen once at program start. api.callbacks.startLoadingSamples.append(self.react_startLoadingSamples) api.callbacks.instrumentStatusChanged.append(self.react_instrumentStatusChanged) api.callbacks.instrumentMidiNoteOnActivity.append(self.react_instrumentMidiNoteOnActivity) #api.callbacks.instrumentMidiNoteOffActivity.append(self.react_instrumentMidiNoteOffActivity) #We don't react to this here but switch off our indicator with a timer. #self.treeWidget.setStyleSheet("QTreeWidget::item { border-bottom: 1px solid black;}") #sadly for all items. We want a line below top level items. if "libraryIsExpanded" in api.session.guiSharedDataToSave: #This is loaded from JSON, where keys MUST be strings. Our ids got converted into strings on save so we have to convert back to int here. correctTypesDict = {} for key, value in api.session.guiSharedDataToSave["libraryIsExpanded"].items(): correctTypesDict[int(key)] = value api.session.guiSharedDataToSave["libraryIsExpanded"] = correctTypesDict else: api.session.guiSharedDataToSave["libraryIsExpanded"] = {} # libId : bool if expanded or not. Also used when switching from nested to flat and back. #Default values are used in self.buildTree def reset(self): """Used on creation and after resetting the sample dir""" self._cachedData = None self._cachedLastInstrumentStatus = {} # instrument idKey : status Dict #The next two will delete all children through the garbage collector. self.guiLibraries = {} # idKey : GuiLibrary idKey is a tuple with second value -1, which would be the instrument. self.guiInstruments = {} # idKey : GuiInstrument def itemExpandedOrCollapsed(self, libraryItem:QtWidgets.QTreeWidgetItem): if type(libraryItem) is GuiLibrary : #just in case api.session.guiSharedDataToSave["libraryIsExpanded"][libraryItem.id] = libraryItem.isExpanded() def currentTreeItemChanged(self, currentTreeItem:QtWidgets.QTreeWidgetItem): """ Program wide GUI-only callback from widget.currentItemChanged->mainWindow.currentTreeItemChanged. We set the currentItem ourselves, so we need to block our signals to avoid recursion. Only one item can be selected at a time. The currentTreeItem we receive is not a global instance but from a widget different to ours. We need to find our local version of the same instrument/library/idKey first. """ if not self.guiLibraries or not self.guiInstruments: #"Show Only Loaded" but no instruments are. #Or Non-Nested view without libraries return self.treeWidget.blockSignals(True) isLibrary = type(currentTreeItem) is GuiLibrary idKey = currentTreeItem.idKey if isLibrary and idKey in self.guiLibraries: assert idKey in self.guiLibraries, (idKey, self.guiLibraries) item = self.guiLibraries[idKey] else: assert idKey in self.guiInstruments, (idKey, self.guiInstruments) item = self.guiInstruments[idKey] self.treeWidget.setCurrentItem(item) #This will work at first, but as soon as the tab instr/favorites is changed this will jump back to a wrong item (first of the group). We have a tabWidget.changed signal in the main window to reset this self.treeWidget.blockSignals(False) def itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int): """This chooses the auditioner. Callbacks are handled in the auditioner widget itself""" if type(item) is GuiInstrument: api.auditionerInstrument(item.idKey) def isNested(self)->bool: return self.parentMainWindow.ui.actionFlatNested.isChecked() def isShowOnlyLoadedInstruments(self)->bool: return self.parentMainWindow.ui.actionShowOnlyLoadedInstruments.isChecked() def buildTree(self, data:dict): """ Create the tree. Can be called multiple times and it will re-create itself destructively. If you call it with data, once at program start, data will get cached. If you call with data=None it will used the cached variant. Data is a dict of dicts and has a hierarchy. data[libId] = dictOfInstruments dictOfInstrument[instrId] = pythonDataDict There is one special "library" key dictOfInstrument["library"] that has metadata for the lib itself. We use that to construct the top level item and sort all others inside Example data {'0': {'0': {'group': 'strings', 'id': '0', 'idKey': ('0', '0'), 'license': 'https://unlicense.org/', 'name': 'Sine Wave', 'tags': ['sine', 'basic'], 'variants': ['Sine.sfz', 'Sine With 5th Slide.sfz'], 'vendor': 'Test entry to provide more vendor information'}, '1': {'group': 'strings', 'id': '1', 'idKey': ('0', '1'), 'license': '', 'name': 'Square Wave', 'tags': ['square', 'basic'], 'variants': ['Square.sfz', 'Square With 5th Slide.sfz'], 'vendor': ''}, '2': {'group': 'brass', 'id': '2', 'idKey': ('0', '2'), 'license': '', 'name': 'Saw Wave', 'tags': ['saw', 'basic'], 'variants': ['Saw.sfz', 'Saw With 5th Slide.sfz'], 'vendor': ''}, '3': {'group': '', 'id': '3', 'idKey': ('0', '3'), 'license': '', 'name': 'Triangle Wave', 'tags': ['triangle', 'complex'], 'variants': ['Triangle.sfz', 'Triangle With 5th Slide.sfz'], 'vendor': ''}, 'library': {'description': 'Basic waveforms. Actual non-looping ' 'samples, created by sox. No sfz ' 'synth-engine involved. There is a variant ' 'with an additional sound-blip for each wave ' 'form, which are purely there as technical ' 'example. They are not a guideline what ' 'constitues a variant and what a different ' 'instrument.', 'id': '0', 'license': 'https://creativecommons.org/publicdomain/zero/1.0/', 'name': 'Tembro Test Instruments', 'tarFilePath': PosixPath('/home/nils/lss/test-data.tar'), 'vendor': 'Hilbricht Nils 2021, Laborejo Software Suite ' 'https://www.laborejo.org info@laborejo.org'}}} """ #Reset everything except our cached data. self.treeWidget.clear() #will delete the C++ objects. We need to delete the PyQt objects ourselves, like so: self.guiLibraries = {} # idKey : GuiLibrary idKey is a tuple with second value -1, which would be the instrument. self.guiInstruments = {} # idKey : GuiInstrument if data: self._cachedData = data else: assert self._cachedData data = self._cachedData nested = self.isNested() showOnlyLoadedInstruments = self.isShowOnlyLoadedInstruments() for libraryId, libraryDict in data.items(): if nested: parentLibraryWidget = GuiLibrary(parentTreeController=self, libraryDict=libraryDict["library"]) self.guiLibraries[(libraryId, -1)] = parentLibraryWidget #-1 marks the library but keeps it a tuple. self.treeWidget.addTopLevelItem(parentLibraryWidget) if libraryId in api.session.guiSharedDataToSave["libraryIsExpanded"]: parentLibraryWidget.setExpanded(api.session.guiSharedDataToSave["libraryIsExpanded"][libraryId]) #only possible after gi.init() was done and item inserted. #parentLibraryWidget.setHidden(True) #only possible after insert atLeastOneVisible = False for instrumentdId, instrumentDict in libraryDict.items(): if instrumentdId == "library": #Top level item was already created. Ignore here. continue if showOnlyLoadedInstruments and instrumentDict["idKey"] in self._cachedLastInstrumentStatus and not self._cachedLastInstrumentStatus[instrumentDict["idKey"]]["state"]: #don't create if we only want to see enabled instrument instrumentVisible = False elif showOnlyLoadedInstruments and not instrumentDict["idKey"] in self._cachedLastInstrumentStatus and not instrumentDict["status"]["state"]: #Not cached yet. This is only good for "load from file". The instruments are not in _cachedLastInstrumentStatus yet instrumentVisible = False else: instrumentVisible = True atLeastOneVisible = True gi = GuiInstrument(parentTreeController=self, instrumentDict=instrumentDict) if nested: parentLibraryWidget.addChild(gi) else: self.treeWidget.addTopLevelItem(gi) gi.injectWidgets() #only possible after gi.init() was done and item inserted. self.guiInstruments[instrumentDict["idKey"]] = gi if instrumentDict["idKey"] in self._cachedLastInstrumentStatus: gi.updateStatus(self._cachedLastInstrumentStatus[instrumentDict["idKey"]]) gi.setHidden(not instrumentVisible) #If no instruments were added maybe the lib needs hiding because it is empty if showOnlyLoadedInstruments and nested and not atLeastOneVisible: parentLibraryWidget.setHidden(True) self._adjustColumnSize() def showOnlyLoadedInstruments(self, state:bool): """The logic is backwards. We receive state=True if instruments should be hidden""" self.buildTree(data=None) #uses the menu state for everything except data def setAllExpanded(self, state:bool): """We do not use the qt function collapseAll and expandAll because they do not trigger the signal""" if self.isNested(): for idKey, guiLib in self.guiLibraries.items(): guiLib.setExpanded(state) def _adjustColumnSize(self): self.treeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue) stateIndex = COLUMNS.index("state") for index in range(self.treeWidget.columnCount()): if not index == stateIndex: self.treeWidget.resizeColumnToContents(index) self.treeWidget.setColumnWidth(index, self.treeWidget.columnWidth(index)+15) #add padding #Fixed width for the switch self.treeWidget.setColumnWidth(stateIndex, 80) def react_instrumentStatusChanged(self, instrumentStatus:dict): self.parentMainWindow.qtApp.restoreOverrideCursor() #Sometimes the instrument was loaded with a cursor animation gi = self.guiInstruments[instrumentStatus["idKey"]] gi.updateStatus(instrumentStatus) self._adjustColumnSize() #We also cache the last status, as we cache the initial data. This way we can delete and recreate TreeItems without requesting new status data from the engine self._cachedLastInstrumentStatus[instrumentStatus["idKey"]] = instrumentStatus def react_startLoadingSamples(self, idKey:tuple): """Will be overriden by instrument status change / variant chosen""" self.parentMainWindow.qtApp.setOverrideCursor(QtCore.Qt.WaitCursor) #reset in self.react_instrumentStatusChanged text = QtCore.QCoreApplication.translate("InstrumentTreeController", "…loading…") loadedIndex = COLUMNS.index("loaded") instr = self.guiInstruments[idKey] instr.setText(loadedIndex, text) self.parentMainWindow.qtApp.processEvents() #actually show the label and cursor def react_instrumentMidiNoteOnActivity(self, idKey:tuple, pitch:int, velocity:int): #First figure out which instrument has activity gi = self.guiInstruments[idKey] gi.activity() #We only do a quick flash here. No need for velocity, pitch or note-off def contextMenu(self, qpoint): #strange that this is not an event but a qpoint item = self.treeWidget.itemAt(qpoint) if not item: return elif type(item) is GuiLibrary: item.contextMenu(qpoint) elif type(item) is GuiInstrument and not item.state: return else: #GuiInstrument and item.state assert item.state externalPort = nestedJackPortsMenu() #returns None or clientAndPortString if externalPort: #None or ClientPortString if externalPort == -1: externalPort = "" api.connectInstrumentPort(item.idKey, externalPort) class GuiLibrary(QtWidgets.QTreeWidgetItem): """The top level library item. All instruments are in a library.""" def __init__(self, parentTreeController, libraryDict): super().__init__([], type=1000) #type 0 is default qt type. 1000 is subclassed user type) self.id = libraryDict["id"] self.idKey = (libraryDict["id"], -1) #fake it for compatibility. -1 means library self.name = libraryDict["name"] #No dynamic data here. Everything gets created once. #self.setText(COLUMNS.index("idKey"), str(libraryDict["id"]).zfill(leadingZeroesForZfill)) self.setData(COLUMNS.index("idKey"), 0, int(libraryDict["id"])) #set data allows sorting by actual numbers. 0 is the data role, which is just "display text". self.setText(COLUMNS.index("name"), str(libraryDict["name"])) self.setText(COLUMNS.index("tags"), str(libraryDict["description"])[:42]+"…") #Hack the row height through an unused column. #self.setText(COLUMNS.index("loaded"), "") #We cannot call setExpanded here. The item must first be inserted into the parent tree. #We placed this call in InstrumentTreeController.buildTree #self.setExpanded(False) icon = parentTreeController.parentMainWindow.style().standardIcon(getattr(QtWidgets.QStyle, "SP_DirIcon")) self.setIcon(COLUMNS.index("name"), icon) def contextMenu(self, qpoint): """This isn't the qt function, but we call this from self.parentTreeController. Logically it makes sense to have it here though""" menu = QtWidgets.QMenu() listOfLabelsAndFunctions = [ (QtCore.QCoreApplication.translate("GuiLibraryContextMenu", "Load whole Library"), lambda: api.loadLibraryInstrumentSamples(self.id)), (QtCore.QCoreApplication.translate("GuiLibraryContextMenu", "Unload whole Library"), lambda: api.unloadLibraryInstrumentSamples(self.id)), ] for text, function in listOfLabelsAndFunctions: if function is None: l = QtWidgets.QLabel(text) l.setAlignment(QtCore.Qt.AlignCenter) a = QtWidgets.QWidgetAction(menu) a.setDefaultWidget(l) menu.addAction(a) else: a = QtWidgets.QAction(text, menu) menu.addAction(a) a.triggered.connect(function) pos = QtGui.QCursor.pos() pos.setY(pos.y() + 5) menu.exec_(pos) class GuiInstrument(QtWidgets.QTreeWidgetItem): """ Why is this not a QTableWidget? As in Agordejo, a TableWidget is a complex item, and inconvenient to use. You need to add an Item to each cell. While in TreeWidget you just create one item. All data is received at program start. No new items will be created, none will get deleted. All instruments in Tembro are static. Most parameters we receive are read only, like instrId, name and version Not all parameters are used in the TreeWidgetItem. e.g. Description, Vendor and License are only for the details view. """ def __init__(self, parentTreeController, instrumentDict): self.parentTreeController = parentTreeController self.idKey = instrumentDict["idKey"] #Start with empty columns. We fill in later in _writeColumns super().__init__([], type=1000) #type 0 is default qt type. 1000 is subclassed user type) self.columns = COLUMNS #Use with: #nameColumnIndex = self.columns.index("prettyName") #self.setText(nameColumnIndex, "hello") self.setTextAlignment(self.columns.index("idKey"), QtCore.Qt.AlignHCenter) self.state = None #by self.update... self.instrumentDict = None self._writeColumns(instrumentDict) self.mutedMixSendStylesheet = """ QSlider::handle:horizontal { background: #5c0000; border-radius: 4px; height: 8px; } """ self.mixSendDial = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.mixSendDial.setMaximumSize(QtCore.QSize(48, 16)) self.mixSendDial.setPageStep(3) self.mixSendDial.setMaximum(0) self.mixSendDial.setMinimum(-21) self.mixSendDial.setValue(-3) self.mixSendDial.valueChanged.connect(self._sendVolumeChangeToEngine) self.mixSendDial.enterEvent = lambda ev: self.parentTreeController.parentMainWindow.statusBar().showMessage((QtCore.QCoreApplication.translate("Instrument", "Use mousewheel to change the instruments mixSend for the stereo mixer ouput. Right click to (un)mute mixer-send."))) self.mixSendDial.leaveEvent = lambda ev: self.parentTreeController.parentMainWindow.statusBar().showMessage("") self.leaveEvent = lambda ev: self.parentTreeController.parentMainWindow.statusBar().showMessage("") self.mixSendDial.contextMenuEvent = self._mixSendDialContextMenuEvent self.mixSendDial.setEnabled(False) #default is off, so we don't send mixSend changes for an unloaded instrument self.toggleSwitch = FancySwitch(track_radius=8,thumb_radius=7) #radius is the actual size, not the rounded corners. #self.toggleSwitch.toggled.connect(lambda c: print('toggled', c)) #triggered by engine callback as well self.toggleSwitch.setAutoFillBackground(True) #otherwise conflicts with setItemWidget self.toggleSwitch.clicked.connect(self.instrumentSwitchOnViaGui) self.toggleSwitch.leaveEvent = lambda ev: self.parentTreeController.parentMainWindow.statusBar().showMessage("") #self.toggleSwitch.pressed.connect(lambda: print('pressed')) #literal mouse down. #self.toggleSwitch.released.connect(lambda: print('released')) #We cannot add the ToggleSwitch Widget here. #It must be inserted after self was added to the Tree. Use self.injectToggleSwitch from parent #icon = parentTreeController.parentMainWindow.style().standardIcon(getattr(QtWidgets.QStyle, "SP_ComputerIcon")) #px = QtGui.QPixmap(32,32) #TODO: get size from standard icon above. but how? Apparently it does not matter how big this is. #px.fill(QtGui.QColor(255,255,255,0)) #transparent icon) #icon = QtGui.QIcon(px) #self.setIcon(COLUMNS.index("name"), icon) #Create icons for midi in status on = QtGui.QPixmap(32,32) oncolor = QtGui.QColor("cyan") on.fill(oncolor) self.onIcon = QtGui.QIcon(on) off = QtGui.QPixmap(32,32) #offcolor = QtGui.QColor(50, 50, 50) #dark grey offcolor = QtGui.QColor(255,255,255,0) #transparent off.fill(offcolor) self.offIcon = QtGui.QIcon(off) self.setIcon(COLUMNS.index("name"), self.offIcon) self.midiActiveFlag = False #midi indicator api.session.eventLoop.verySlowConnect(self._activityOff) #this is always on, even if no samples loaded. The auditioner sends activeOn, even if state==False def instrumentSwitchOnViaGui(self, state): """Only GUI clicks. Does not react to the engine callback that switches on instruments. For example one that arrives through "load group" or "load all" We use this to count, across program runs, how many times this instrument was activated and present it as favourites. The engine does not know about this. Loading states from save files, "load all" and other algorithmic loaders do not contribute to this counting. """ if state: api.loadInstrumentSamples(self.idKey) self.parentTreeController.parentMainWindow.favoriteInstrument(self.idKey) else: api.unloadInstrumentSamples(self.idKey) def updateStatus(self, instrumentStatus:dict): #Before we set the state permanently we use the opportunity to see if this is program state (state == None) or unloading self.cachedInstrumentStatus = instrumentStatus firstLoad = self.state is None variantColumnIndex = self.columns.index("loaded") self.currentVariant = instrumentStatus["currentVariant"] #if instrumentStatus["currentVariant"]: #None if not loaded or not enabled anymore self.setText(variantColumnIndex, instrumentStatus["currentVariant"].rstrip(".sfz")) #either "" or a variant self.state = instrumentStatus["state"] #is no bool and not None. self.toggleSwitch.setChecked(instrumentStatus["state"]) self.mixSendDial.setStyleSheet("") self.mixSendDial.setUpdatesEnabled(True) if self.state: #either reload or load for the first time self.mixSendDial.setEnabled(True) #Qslider can only have integer values. mixerLevel" is float. #We simply drop the float part, it doesn't matter for our coarse in-house mixing. self.mixSendDial.setValue(int(instrumentStatus["mixerLevel"])) if instrumentStatus["mixerEnabled"]: muteText = "" else: muteText = QtCore.QCoreApplication.translate("Instrument", "[Muted]") self.mixSendDial.setStyleSheet(self.mutedMixSendStylesheet) self.parentTreeController.parentMainWindow.statusBar().showMessage((QtCore.QCoreApplication.translate("Instrument", "{} send to mix volume: {} {}".format(self.instrumentDict["name"], instrumentStatus["mixerLevel"], muteText)))) #api.session.eventLoop.verySlowConnect(self._activityOff) #this is always on, even if not loaded. The auditioner sends activeOn, even if state==False elif not firstLoad: #and not self.state #the instrument was once loaded and is currently connected pass #api.session.eventLoop.verySlowDisconnect(self._activityOff) if not self.state: #in any not-case self.mixSendDial.setEnabled(False) self.mixSendDial.setUpdatesEnabled(False) #this is a hack to make the widget disappear. Because hiding a QTreeWidgetItem does not work. self.parentTreeController.parentMainWindow.qtApp.processEvents() #actually show the new state, even if mass unloading (which did not work before. But loading worked!) def _mixSendDialContextMenuEvent(self, event): if self.cachedInstrumentStatus["mixerEnabled"]: mixerMuteText = QtCore.QCoreApplication.translate("InstrumentMixerLevelContextMenu", "Mute/Disable Mixer-Send for {}".format(self.instrumentDict["name"])) mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idKey, False) else: mixerMuteText = QtCore.QCoreApplication.translate("InstrumentMixerLevelContextMenu", "Unmute/Enable Mixer-Send for {}".format(self.instrumentDict["name"])) mixerMuteFunc = lambda: api.setInstrumentMixerEnabled(self.idKey, True) listOfLabelsAndFunctions = [ (mixerMuteText, mixerMuteFunc), ] menu = QtWidgets.QMenu() for text, function in listOfLabelsAndFunctions: if function is None: l = QtWidgets.QLabel(text) l.setAlignment(QtCore.Qt.AlignCenter) a = QtWidgets.QWidgetAction(menu) a.setDefaultWidget(l) menu.addAction(a) else: a = QtWidgets.QAction(text, menu) menu.addAction(a) a.triggered.connect(function) pos = QtGui.QCursor.pos() pos.setY(pos.y() + 5) menu.exec_(pos) def injectWidgets(self): """Call this after the item was added to the tree. Widgets must be inserted after the Item was created""" stateColumnIndex = self.columns.index("state") self.parentTreeController.treeWidget.setItemWidget(self, stateColumnIndex, self.toggleSwitch) mixSendColumnIndex = self.columns.index("mixSend") self.parentTreeController.treeWidget.setItemWidget(self, mixSendColumnIndex, self.mixSendDial) def _writeColumns(self, instrumentDict): """This is used to construct the columns when the program starts. There is an update callback for dynamic values as well""" self.instrumentDict = instrumentDict for index, key in enumerate(self.columns): QtCore.QCoreApplication.translate("OpenSession", "not saved") if key == "state" or key == "loaded" or key == "mixSend": pass #this arrives through a different api.callback elif type(instrumentDict[key]) is str: self.setText(index, str(instrumentDict[key])) elif key == "tags": #list t = ", ".join(instrumentDict[key]) self.setText(index, t) elif key == "idKey": #tuple libId, instrId = instrumentDict[key] zeros = int(instrumentDict["instrumentsInLibraryCount"]/10)+1 instIdZFilled = str(instrId).zfill(zeros) if self.parentTreeController.isNested(): self.setText(index, instIdZFilled) else: #full id #self.setText(index, f"{libId}-{str(instrId).zfill(zeros)}") self.setData(index, 0, float(str(libId) + "." + instIdZFilled)) #0 is the data role, just standard display text. We combine both IDs to a float number for sorting. If used with setData instead of setText Qt will know how to sort 11 before 1000 """ elif key == "state": #use parameter for initial value. loaded from file or default = False. state = instrumentDict[key] assert type(state) is bool, state self.switch(state) """ def _activityOff(self): """Called by a timer""" if self.midiActiveFlag: self.midiActiveFlag = False self.setIcon(COLUMNS.index("name"), self.offIcon) def activity(self): """Show midi note ons as flashing light.""" self.midiActiveFlag = True self.setIcon(COLUMNS.index("name"), self.onIcon) def _sendVolumeChangeToEngine(self, newValue): self.mixSendDial.blockSignals(True) api.setInstrumentMixerVolume(self.idKey, newValue) self.mixSendDial.blockSignals(False)