Music production session manager
https://www.laborejo.org
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
378 lines
18 KiB
378 lines
18 KiB
#! /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 <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
|
|
|
|
#Qt
|
|
from .helper import sizeof_fmt
|
|
from .projectname import ProjectNameWidget
|
|
from .projectname import NewSessionDialog
|
|
from .waitdialog import WaitDialog
|
|
|
|
class DirectoryItem(QtWidgets.QTreeWidgetItem):
|
|
"""A plain directory with no session content"""
|
|
|
|
def __lt__(self, other):
|
|
"""Treeview uses only less than.
|
|
less equal, both greater and equal are not used.
|
|
|
|
We always want to be on top, no matter what column or sort order
|
|
"""
|
|
if type(other) is SessionItem:
|
|
if self.treeWidget().header().sortIndicatorOrder(): #descending?
|
|
return False
|
|
else:
|
|
return True
|
|
else:
|
|
return QtWidgets.QTreeWidgetItem.__lt__(self, other)
|
|
|
|
class SessionItem(QtWidgets.QTreeWidgetItem):
|
|
"""Subclass to enable sorting of size by actual value, not by human readable display.
|
|
entry["nsmSessionName"] = projectName
|
|
entry["name"] = os.path.basename(projectName)
|
|
entry["lastSavedDate"] = "2016-05-21 16:36"
|
|
entry["fullPath"] = actual path
|
|
entry["sizeInBytes"] = 623623
|
|
entry["numberOfClients"] = 3
|
|
entry["hasSymlinks"] = True
|
|
entry["parents"] = []
|
|
|
|
Also:
|
|
entry["locked"] = True
|
|
"""
|
|
|
|
allItems = {} #nsmSessionName : SessionItem
|
|
|
|
def __init__(self, sessionDict):
|
|
SessionItem.allItems[sessionDict["nsmSessionName"]] = self
|
|
|
|
self.sessionDict = sessionDict
|
|
|
|
symlinks = "Yes" if sessionDict["hasSymlinks"] else "No" #TODO: Translate
|
|
parameterList = [sessionDict["name"], sessionDict["lastSavedDate"], str(sessionDict["numberOfClients"]), sizeof_fmt(sessionDict["sizeInBytes"]), symlinks, sessionDict["fullPath"], ]
|
|
super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type)
|
|
|
|
self.setTextAlignment(2, QtCore.Qt.AlignHCenter) #clients
|
|
self.setTextAlignment(4, QtCore.Qt.AlignHCenter) #symlinks
|
|
|
|
self.setLocked(sessionDict["locked"])
|
|
|
|
|
|
def updateData(self):
|
|
"""Actively queries the api for new data"""
|
|
sessionDict = api.sessionQuery(self.sessionDict["nsmSessionName"])
|
|
self.sessionDict = sessionDict
|
|
self.setText(0, sessionDict["name"])
|
|
self.setText(1, sessionDict["lastSavedDate"])
|
|
self.setText(2, str(sessionDict["numberOfClients"]))
|
|
self.setText(3, sizeof_fmt(sessionDict["sizeInBytes"]))
|
|
self.setText(4, "Yes" if sessionDict["hasSymlinks"] else "No") #TODO: Translate
|
|
self.setText(5, sessionDict["fullPath"])
|
|
|
|
def setLocked(self, state:bool):
|
|
"""Number of clients, symlinks and size change frequently while a session is open/locked.
|
|
We deactivate the display of these values while locked
|
|
|
|
This is also used for nsmd lockfiles and locked sessions, aka session open by another nsmd.
|
|
"""
|
|
self.updateData()
|
|
self.setDisabled(state)
|
|
|
|
def updateTimestamp(self, timestamp:str):
|
|
#Column 1 "Last Save"
|
|
self.setText(1, timestamp)
|
|
|
|
def __lt__(self, other):
|
|
"""Treeview uses only less than.
|
|
less equal, both greater and equal are not used.
|
|
|
|
There is no check between two directory-items here because these are standard WidgetItems
|
|
"""
|
|
if type(other) is DirectoryItem: #Just a dir
|
|
return False #we are "greater"=later
|
|
|
|
column = self.treeWidget().sortColumn()
|
|
if column == 3: #bytes
|
|
return self.sessionDict["sizeInBytes"] > other.sessionDict["sizeInBytes"]
|
|
|
|
elif column == 2: #number of clients
|
|
return self.sessionDict["numberOfClients"] > other.sessionDict["numberOfClients"]
|
|
else:
|
|
return QtWidgets.QTreeWidgetItem.__lt__(self, other)
|
|
|
|
class SessionTreeController(object):
|
|
"""Controls a treeWidget, but does not subclass"""
|
|
|
|
def __init__(self, mainWindow):
|
|
self.mainWindow = mainWindow
|
|
self.treeWidget = mainWindow.ui.session_tree
|
|
|
|
self._cachedSessionDicts = None
|
|
self.mainWindow.ui.checkBoxNested.stateChanged.connect(self._reactSignal_nestedFlatChanged)
|
|
self._reactSignal_nestedFlatChanged(self.mainWindow.ui.checkBoxNested.isChecked()) #initial state
|
|
|
|
#Configure the treewidget
|
|
#columns: name, path (relative from session dir), number of programs, disk size (links resolved?)
|
|
self.treeWidget.setColumnCount(4)
|
|
self.headerLabels = [
|
|
QtCore.QCoreApplication.translate("SessionTree", "Name"),
|
|
QtCore.QCoreApplication.translate("SessionTree", "Last Save"),
|
|
QtCore.QCoreApplication.translate("SessionTree", "Clients"),
|
|
QtCore.QCoreApplication.translate("SessionTree", "Size"),
|
|
QtCore.QCoreApplication.translate("SessionTree", "Symlinks"),
|
|
QtCore.QCoreApplication.translate("SessionTree", "Path"),
|
|
]
|
|
self.treeWidget.setHeaderLabels(self.headerLabels)
|
|
self.treeWidget.setSortingEnabled(True)
|
|
self.treeWidget.setAlternatingRowColors(True)
|
|
self.treeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
|
|
|
#TODO: save sorting in user-wide qt application settings
|
|
#We remember sorting via signals layoutAboutToBeChanged and restore via layoutChanged
|
|
self.sortByColumnValue = 0 #by name
|
|
self.sortDescendingValue = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
|
|
self.treeWidget.header().setSortIndicator(0,0) #Hack/Workaround. On startup it is not enough to set sorting. New items will be added in a random position. Maybe that is our async network adding.
|
|
#self.treeWidget.sortByColumn(self.sortByColumnValue, self.sortDescendingValue)
|
|
|
|
api.callbacks.sessionsChanged.append(self._reactCallback_sessionsChanged)
|
|
api.callbacks.sessionLocked.append(self._reactCallback_sessionLocked)
|
|
api.callbacks.sessionFileChanged.append(self._reactCallback_sessionFileChanged)
|
|
|
|
self.treeWidget.currentItemChanged.connect(self._reactSelectionChanged) #click anywhere
|
|
self.treeWidget.itemDoubleClicked.connect(self._reactSignal_itemDoubleClicked)
|
|
self.treeWidget.customContextMenuRequested.connect(self.contextMenu)
|
|
self.treeWidget.itemExpanded.connect(self._reactSignal_itemExpanded)
|
|
self.treeWidget.itemCollapsed.connect(self._reactSignal_itemExpanded)
|
|
self.treeWidget.model().layoutAboutToBeChanged.connect(self._reactSignal_rememberSorting)
|
|
#self.treeWidget.model().layoutChanged.connect(self._reactSignal_restoreSorting)
|
|
self.mainWindow.ui.button_new_session.clicked.connect(self._reactSignal_newSession)
|
|
self.mainWindow.ui.button_new_quick_session.clicked.connect(api.sessionNewTimestamped)
|
|
|
|
#The next ones are only available if a session item is currently selected. The connected lambda functions do not test for this, but we only enable the buttons when such an item is selected.
|
|
self.mainWindow.ui.button_load_selected_session.clicked.connect(self._reactSignal_openSelected)
|
|
self.mainWindow.ui.button_copy_selected_session.clicked.connect(lambda: self._askForCopyAndCopy(self.treeWidget.currentItem().sessionDict["nsmSessionName"]))
|
|
self.mainWindow.ui.button_rename_selected_session.clicked.connect(lambda: self._askForNameAndRenameSession(self.treeWidget.currentItem().sessionDict["nsmSessionName"]))
|
|
self.mainWindow.ui.button_delete_selected_session.clicked.connect(lambda: self.deleteSessionItem(self.treeWidget.currentItem()))
|
|
|
|
logger.info("Full View Session Chooser ready")
|
|
|
|
def _reactCallback_sessionFileChanged(self, name:str, timestamp:str):
|
|
"""Timestamp of "last saved" changed"""
|
|
SessionItem.allItems[name].updateTimestamp(timestamp)
|
|
|
|
def _reactCallback_sessionLocked(self, name:str, state:bool):
|
|
SessionItem.allItems[name].setLocked(state)
|
|
|
|
def _reactCallback_sessionsChanged(self, sessionDicts:list):
|
|
"""Main callback for new, added, removed, moved sessions etc.
|
|
We also get this for every client change so we can update our numbers"""
|
|
self.treeWidget.clear()
|
|
|
|
self._cachedSessionDicts = sessionDicts #in case we change the flat/nested mode.
|
|
|
|
for sessionDict in sessionDicts:
|
|
self.addSessionItem(sessionDict)
|
|
|
|
for i in range(len(self.headerLabels)):
|
|
self.treeWidget.resizeColumnToContents(i)
|
|
#Make the name column a few pixels wider
|
|
self.treeWidget.setColumnWidth(0, self.treeWidget.columnWidth(0) + 25)
|
|
self.treeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue)
|
|
|
|
|
|
def _addItemNested(self, sessionDict:dict):
|
|
assert sessionDict, sessionDict
|
|
item = SessionItem(sessionDict)
|
|
if sessionDict["parents"]:
|
|
#These are already a hirarchy, sorted from parents to children
|
|
last = None #first is toplevel
|
|
for parentDir in sessionDict["parents"]:
|
|
alreadyExist = self.treeWidget.findItems(parentDir, QtCore.Qt.MatchExactly|QtCore.Qt.MatchRecursive, column=0)
|
|
if alreadyExist:
|
|
directoryItem = alreadyExist[0]
|
|
else:
|
|
directoryItem = DirectoryItem([parentDir])
|
|
#directoryItem = QtWidgets.QTreeWidgetItem([parentDir], 0) #type 0 is qt default
|
|
|
|
if last:
|
|
last.addChild(directoryItem)
|
|
else:
|
|
self.treeWidget.addTopLevelItem(directoryItem)
|
|
last = directoryItem
|
|
#After the loop: All subdirs built. Now add the item to the last one
|
|
last.addChild(item)
|
|
else:
|
|
self.treeWidget.addTopLevelItem(item)
|
|
|
|
def _addItemFlat(self, sessionDict:dict):
|
|
assert sessionDict, sessionDict
|
|
sessionDict["name"] = sessionDict["nsmSessionName"]
|
|
item = SessionItem(sessionDict)
|
|
self.treeWidget.addTopLevelItem(item)
|
|
|
|
def addSessionItem(self, sessionDict:dict):
|
|
if self.mode == "nested":
|
|
self._addItemNested(sessionDict)
|
|
elif self.mode == "flat":
|
|
self._addItemFlat(sessionDict)
|
|
else:
|
|
raise ValueError("Unknown SessionTree display mode")
|
|
|
|
def deleteSessionItem(self, item:SessionItem):
|
|
"""Instruct the engine to fully delete a complete session item.
|
|
Will show a warning before."""
|
|
text = QtCore.QCoreApplication.translate("SessionTree", "About to delete Session {}").format(item.sessionDict["nsmSessionName"])
|
|
informativeText = QtCore.QCoreApplication.translate("SessionTree", "All files in the project directory will be irreversibly deleted.")
|
|
title = QtCore.QCoreApplication.translate("SessionTree", "All files in the project directory will be irreversibly deleted.")
|
|
title = QtCore.QCoreApplication.translate("SessionTree", "About to delete Session {}").format(item.sessionDict["nsmSessionName"])
|
|
|
|
box = QtWidgets.QMessageBox(self.treeWidget)
|
|
box.setIcon(box.Warning)
|
|
box.setText(text)
|
|
box.setWindowTitle(title)
|
|
box.setInformativeText(informativeText)
|
|
|
|
keep = box.addButton(QtCore.QCoreApplication.translate("SessionTree", "Keep Session"), box.RejectRole)
|
|
box.addButton(QtCore.QCoreApplication.translate("SessionTree", "Delete!"), box.AcceptRole)
|
|
box.setDefaultButton(keep)
|
|
ret = box.exec() #0 or 1. Return values are NOT the button roles.
|
|
|
|
if ret: #Delete
|
|
api.sessionDelete(item.sessionDict["nsmSessionName"])
|
|
|
|
|
|
def contextMenu(self, qpoint):
|
|
item = self.treeWidget.itemAt(qpoint)
|
|
if not type(item) is SessionItem:
|
|
return
|
|
|
|
menu = QtWidgets.QMenu()
|
|
|
|
listOfLabelsAndFunctions = [
|
|
(QtCore.QCoreApplication.translate("SessionTree", "Copy Session"), lambda: self._askForCopyAndCopy(item.sessionDict["nsmSessionName"]))
|
|
]
|
|
if not item.isDisabled() and not item.sessionDict["locked"]:
|
|
listOfLabelsAndFunctions.append((QtCore.QCoreApplication.translate("SessionTree", "Rename Session"), lambda: self._askForNameAndRenameSession(item.sessionDict["nsmSessionName"])))
|
|
#Delete should be the bottom item.
|
|
listOfLabelsAndFunctions.append((QtCore.QCoreApplication.translate("SessionTree", "Delete Session"), lambda: self.deleteSessionItem(item)))
|
|
|
|
|
|
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)
|
|
|
|
|
|
#GUI Signals
|
|
|
|
def _reactSelectionChanged(self, item, previous):
|
|
"""User clicks on an entry in the session chooser, or in the empty space.
|
|
in any case, the selection changes and we can decide if we activate/deactivate certain buttons"""
|
|
if not item or not type(item) is SessionItem or item.sessionDict["locked"] == True:
|
|
sessionSelectedState = False
|
|
else:
|
|
sessionSelectedState = True
|
|
|
|
self.mainWindow.ui.button_load_selected_session.setEnabled(sessionSelectedState)
|
|
self.mainWindow.ui.button_copy_selected_session.setEnabled(sessionSelectedState)
|
|
self.mainWindow.ui.button_rename_selected_session.setEnabled(sessionSelectedState)
|
|
self.mainWindow.ui.button_delete_selected_session.setEnabled(sessionSelectedState)
|
|
|
|
def _reactSignal_itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int):
|
|
if not item.isDisabled() and type(item) is SessionItem:
|
|
api.sessionOpen(item.sessionDict["nsmSessionName"])
|
|
|
|
def _reactSignal_itemExpanded(self, item:QtWidgets.QTreeWidgetItem):
|
|
"""Also for collapsed!"""
|
|
for i in range(len(self.headerLabels)):
|
|
self.treeWidget.resizeColumnToContents(i)
|
|
|
|
def _reactSignal_openSelected(self):
|
|
item = self.treeWidget.currentItem()
|
|
if item:
|
|
self._reactSignal_itemDoubleClicked(item, column=0)
|
|
|
|
def _reactSignal_newSession(self):
|
|
widget = NewSessionDialog(parent=self.treeWidget, startwith="")
|
|
#widget = ProjectNameWidget(parent=self.treeWidget, startwith="")
|
|
if widget.result:
|
|
#result = {"name":str, "startclients":list}
|
|
api.sessionNew(widget.result["name"], widget.result["startclients"])
|
|
|
|
def _askForCopyAndCopy(self, nsmSessionName:str):
|
|
"""Called by button and context menu"""
|
|
copyString = QtCore.QCoreApplication.translate("SessionTree", "Copying {} to {}")
|
|
widget = ProjectNameWidget(parent=self.treeWidget, startwith=nsmSessionName+"-copy")
|
|
if widget.result:
|
|
logger.info("Asking api to copy a session while waiting")
|
|
copyString = copyString.format(nsmSessionName, widget.result)
|
|
def longrunningfunction(progressHook):
|
|
api.sessionCopy(nsmSessionName, widget.result, progressHook)
|
|
diag = WaitDialog(self.mainWindow, copyString, longrunningfunction) #save in local var to keep alive
|
|
|
|
#Somehow session list is wrong, symlinks are not calculated. Force update.
|
|
api.requestSessionList()
|
|
|
|
def _askForNameAndRenameSession(self, nsmSessionName:str):
|
|
"""Only for non-locked sessions. Context menu is only available if not locked."""
|
|
widget = ProjectNameWidget(parent=self.treeWidget, startwith=nsmSessionName)
|
|
if widget.result and not widget.result == nsmSessionName:
|
|
api.sessionRename(nsmSessionName, widget.result)
|
|
|
|
def _reactSignal_rememberSorting(self, *args):
|
|
self.sortByColumnValue = self.treeWidget.header().sortIndicatorSection()
|
|
self.sortDescendingValue = self.treeWidget.header().sortIndicatorOrder()
|
|
|
|
def _reactSignal_restoreSorting(self, *args):
|
|
"""Do not use as signal!!! Will lead to infinite recursion since Qt 5.12.2"""
|
|
#self.treeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue)
|
|
raise RuntimeError()
|
|
|
|
def _reactSignal_nestedFlatChanged(self, checkStatus:bool):
|
|
"""#flat does not create directory items but changes the session name to dir/foo/bar"""
|
|
if checkStatus:
|
|
self.mode = "nested"
|
|
else:
|
|
self.mode = "flat"
|
|
#And rebuild the items without fetching new data.
|
|
if self._cachedSessionDicts: #not startup
|
|
self._reactCallback_sessionsChanged(self._cachedSessionDicts)
|
|
self.treeWidget.sortItems(self.sortByColumnValue, self.sortDescendingValue)
|
|
|