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

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