From c98efbd6d7aa6e262b7152b10e8181291282886e Mon Sep 17 00:00:00 2001 From: Nils <> Date: Wed, 1 Apr 2020 16:40:40 +0200 Subject: [PATCH] initial commit --- argodejo | 8 + engine/api.py | 328 ++++++ engine/config.py | 45 + engine/findprograms.py | 230 ++++ engine/nsmservercontrol.py | 1603 +++++++++++++++++++++++++++ engine/old | 33 + engine/start.py | 220 ++++ engine/watcher.py | 154 +++ icon.png | Bin 0 -> 1772 bytes qtgui/addclientprompt.py | 110 ++ qtgui/descriptiontextwidget.py | 99 ++ qtgui/designer/mainwindow.py | 356 ++++++ qtgui/designer/mainwindow.ui | 670 +++++++++++ qtgui/designer/newsession.py | 52 + qtgui/designer/newsession.ui | 110 ++ qtgui/designer/projectname.py | 45 + qtgui/designer/projectname.ui | 52 + qtgui/eventloop.py | 75 ++ qtgui/helper.py | 181 +++ qtgui/mainwindow.py | 448 ++++++++ qtgui/opensessioncontroller.py | 482 ++++++++ qtgui/projectname.py | 160 +++ qtgui/quickopensessioncontroller.py | 321 ++++++ qtgui/quicksessioncontroller.py | 108 ++ qtgui/sessiontreecontroller.py | 343 ++++++ qtgui/systemtray.py | 105 ++ qtgui/waitdialog.py | 76 ++ tools/nsm-data.py | 292 +++++ tools/nsmclient.py | 1 + tools/nsmcmdline.py | 216 ++++ tools/nsmservercontrol.py | 1 + 31 files changed, 6924 insertions(+) create mode 100755 argodejo create mode 100644 engine/api.py create mode 100644 engine/config.py create mode 100644 engine/findprograms.py create mode 100644 engine/nsmservercontrol.py create mode 100644 engine/old create mode 100644 engine/start.py create mode 100644 engine/watcher.py create mode 100644 icon.png create mode 100644 qtgui/addclientprompt.py create mode 100644 qtgui/descriptiontextwidget.py create mode 100644 qtgui/designer/mainwindow.py create mode 100644 qtgui/designer/mainwindow.ui create mode 100644 qtgui/designer/newsession.py create mode 100644 qtgui/designer/newsession.ui create mode 100644 qtgui/designer/projectname.py create mode 100644 qtgui/designer/projectname.ui create mode 100644 qtgui/eventloop.py create mode 100644 qtgui/helper.py create mode 100644 qtgui/mainwindow.py create mode 100644 qtgui/opensessioncontroller.py create mode 100644 qtgui/projectname.py create mode 100644 qtgui/quickopensessioncontroller.py create mode 100644 qtgui/quicksessioncontroller.py create mode 100644 qtgui/sessiontreecontroller.py create mode 100644 qtgui/systemtray.py create mode 100644 qtgui/waitdialog.py create mode 100755 tools/nsm-data.py create mode 120000 tools/nsmclient.py create mode 100644 tools/nsmcmdline.py create mode 120000 tools/nsmservercontrol.py diff --git a/argodejo b/argodejo new file mode 100755 index 0000000..1901029 --- /dev/null +++ b/argodejo @@ -0,0 +1,8 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +from engine import start + +from qtgui import mainwindow #which in turn imports the engine and starts the engine +mainwindow.MainWindow() +#Program is over. Code here does not get executed. diff --git a/engine/api.py b/engine/api.py new file mode 100644 index 0000000..2da6eaa --- /dev/null +++ b/engine/api.py @@ -0,0 +1,328 @@ +#! /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 ) + +The Template Base 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 + +#Our Modules +from engine.start import PATHS + +from .nsmservercontrol import NsmServerControl +from .watcher import Watcher +from .findprograms import programDatabase + +class Callbacks(object): + """GUI methods register themselves here. + These methods get called by us, the engine. + + None of these methods produce any return value. + + The lists may be unordered. + + We need the lists for audio feedbacks in parallel to GUI updates. + Or whatever parallel representations we run.""" + + def __init__(self): + self.message = [] + + #Session Management + self.sessionOpenReady = [] + self.sessionOpenLoading = [] + self.sessionClosed = [] + self.sessionsChanged = [] #update in the file structure. redraw list of sessions. + self.sessionLocked = [] # incremental update. Sends the name of the session project and a bool if locked + self.sessionFileChanged = [] #incremental update. Reports the session name, not the session file + self.clientStatusChanged = [] #every status including GUI and dirty + self.singleInstanceActivateWindow = [] #this is for the single-instance feature. Show the GUI window and activate it when this signal comes. + self.dataClientNamesChanged = [] + self.dataClientDescriptionChanged = [] + + def _dataClientNamesChanged(self, data): + """If there is a dataclient in the session it will allow us to read and write metadata. + The GUI instructs us to send a write instruction over OSC, we wait for the OSC answer + and forward that to the GUI. + If the dataClient joins the session it will trigger an unrequested callback (from the GUIs + perspectvive). If the client leaves the callback will send a single None. This is a sign + for the GUI to reset all data to the nsmd state. We do not mix nsmd data and dataClient. A + dataclient can join and leave at every time, we keep the GUI informed. """ + for func in self.dataClientNamesChanged: + func(data) + + def _dataClientDescriptionChanged(self, data): + """see _dataClientNamesChanged. + In short: str for data, None if nsm-data leaves session""" + for func in self.dataClientDescriptionChanged: + func(data) + + def _singleInstanceActivateWindow(self): + for func in self.singleInstanceActivateWindow: + func() + + def _sessionOpenReady(self, nsmSessionExportDict): + """A project got opened, most likely by ourselves, but also by another party. + This will also fire if we start and detect that a session is already open and running. + """ + for func in self.sessionOpenReady: + func(nsmSessionExportDict) + + def _sessionOpenLoading(self, nsmSessionExportDict): + """ + A session begins loading. Show a spinning clock or so... + """ + for func in self.sessionOpenLoading: + func(nsmSessionExportDict) + + def _sessionClosed(self): + """The current session got closed. Present a list of sessions to choose from again. + This is also send at GUI start if there is no session open presently.""" + for func in self.sessionClosed: + func() + + def _sessionsChanged(self): + """The project list changed. + This can happen through internal changes like deletion or duplication + but also through external changes through a filemanager. + + Always sends a full update of everything, with no indication of what changed.""" + listOfProjectDicts = nsmServerControl.exportSessionsAsDicts() + + for func in self.sessionsChanged: + func(listOfProjectDicts) + + return listOfProjectDicts + + def _sessionLocked(self, name:str, status:bool): + """Called by the Watcher through the event loop + Name is "nsmSessionName" from _nsmServerControl.exportSessionsAsDicts + Sends True if project is locked""" + for func in self.sessionLocked: + func(name, status) + + def _sessionFileChanged(self, name:str, timestamp:str): + """ + This happens everytime the session gets saved. + + Called by the Watcher through the event loop. + Name is "nsmSessionName" from _nsmServerControl.exportSessionsAsDicts. + timestamp has same format as nsmServerControl.exportSessionsAsDicts""" + for func in self.sessionFileChanged: + func(name, timestamp) + + def _clientStatusChanged(self, clientInfoDict:dict): + """A single function for all client changes. Adding and deletion is also included. + GUI hide/show and dirty is also here. + A GUI needs to check if it already knows the clientId or not.""" + for func in self.clientStatusChanged: + func(clientInfoDict) + + +def startEngine(): + logger.info("Start Engine") + + global eventLoop + assert eventLoop + + global nsmServerControl + nsmServerControl = NsmServerControl( + sessionOpenReadyHook=callbacks._sessionOpenReady, + sessionOpenLoadingHook=callbacks._sessionOpenLoading, + sessionClosedHook=callbacks._sessionClosed, + clientStatusHook=callbacks._clientStatusChanged, + singleInstanceActivateWindowHook=callbacks._singleInstanceActivateWindow, + dataClientNamesHook=callbacks._dataClientNamesChanged, + dataClientDescriptionHook=callbacks._dataClientDescriptionChanged, + parameterNsmOSCUrl=PATHS["url"], + sessionRoot=PATHS["sessionRoot"], + ) + + #Watch session tree for changes. + global sessionWatcher + sessionWatcher = Watcher(nsmServerControl) + sessionWatcher.timeStampHook = callbacks._sessionFileChanged + sessionWatcher.lockFileHook = callbacks._sessionLocked + sessionWatcher.sessionsChangedHook = callbacks._sessionsChanged #This is the main callback that informs of new or updated sessions + callbacks.sessionClosed.append(sessionWatcher.resume) #Watcher only active in "Choose a session mode" + callbacks.sessionOpenReady.append(sessionWatcher.suspend) + eventLoop.slowConnect(sessionWatcher.process) + + #Start Event Loop Processing + eventLoop.fastConnect(nsmServerControl.process) + eventLoop.slowConnect(nsmServerControl.processSingleInstance) + + #Send initial data + #The decision if we are already in a session on startup or in "choose a session mode" is handled by callbacks + #This is not to actually gather the data, but only to inform the GUI. + callbacks._sessionsChanged() + + #nsmServerControl blocks until it has a connection to nsmd. That means at this point we are ready to send commands. + #Until we return from startEngine a GUI will also not create its mainwindow. This can be used to create the appearance + #of a session-on-load: + if PATHS["startupSession"]: + sessionOpen(PATHS["startupSession"]) + +#Info +def sessionRoot(): + return nsmServerControl.sessionRoot + +def currentSession(): + return nsmServerControl.internalState["currentSession"] + +def sessionList(): + """Updates the list each call. Use only this from a GUI for active query. + Otherwise sessionRemove and sessionCopy will not have updated the list""" + r = nsmServerControl.exportSessionsAsDicts() + return [s["nsmSessionName"] for s in r] + +def buildSystemPrograms(): + """Build a list of dicts with the .desktop files (or similar) of all NSM compatible programs + present on the system""" + programDatabase.build() + +def getSystemPrograms(): + """Returns the cached database from buildProgramDatabase. No automatic update. Empty on program + start""" + return programDatabase.programs + +def setSystemsPrograms(listOfDicts:list): + programDatabase.loadPrograms(listOfDicts) + +def getNsmExecutables()->set: + """Cached access fort fast membership tests. Is this program in the PATH?""" + return programDatabase.nsmExecutables + +def getUnfilteredExecutables(): + """Return a list of unique names without paths or directories of all exectuables in users $PATH. + This is intended for a program starter prompt. GUI needs to supply tab completition or search + itself""" + return programDatabase.unfilteredExecutables + + +#Session Control +#No project running +#There is no callback for _sessionsChanged because we poll that in the event loop. + +def sessionNew(newName:str, startClients:list=[]): + nsmServerControl.new(newName, startClients) + +def sessionRename(nsmSessionName:str, newName:str): + """only for non-open sessions""" + nsmServerControl.renameSession(nsmSessionName, newName) + +def sessionCopy(nsmSessionName:str, newName:str): + """Create a copy of the session. Removes the lockfile, if any. + Has some safeguards inside so it will not crash.""" + nsmServerControl.copySession(nsmSessionName, newName) + +def sessionOpen(nsmSessionName:str): + """Saves the current session and loads a different existing session.""" + nsmServerControl.open(nsmSessionName) + +def sessionQuery(nsmSessionName:str): + """For the occasional out-of-order information query. + Exports a single session project in the format of nsmServerControl.exportSessionsAsDicts""" + return nsmServerControl.sessionAsDict(nsmSessionName) + +def sessionForceLiftLock(nsmSessionName:str): + nsmServerControl.forceLiftLock(nsmSessionName) + callbacks._sessionLocked(nsmSessionName, False) + +def sessionDelete(nsmSessionName:str): + nsmServerControl.deleteSession(nsmSessionName) + +#While Project is open +def sessionSave(): + """Saves the current session.""" + nsmServerControl.save() + +def sessionClose(blocking=False): + """Saves and closes the current session.""" + nsmServerControl.close(blocking) + +def sessionAbort(blocking=False): + """Close without saving the current session.""" + nsmServerControl.abort(blocking) + +def sessionSaveAs(nsmSessionName:str): + """Duplicate in NSM terms. Make a copy, close the current one, open the new one. + However, it will NOT send the session clossed signal, just a session changed one.""" + nsmServerControl.duplicate(nsmSessionName) + +def setDescription(text:str): + nsmServerControl.setDescription(text) + +#Client Handling +def clientAdd(executableName): + nsmServerControl.clientAdd(executableName) + #status hook triggers clientStatusChanged callback + +def clientStop(clientId:str): + nsmServerControl.clientStop(clientId) + +def clientResume(clientId:str): + """Opposite of clientStop""" + nsmServerControl.clientResume(clientId) + +def clientRemove(clientId:str): + """Client must be already stopped! We will do that without further question. + Remove from the session. Will not delete the save-files, but make them inaccesible""" + nsmServerControl.clientRemove(clientId) + +def clientSave(clientId:str): + """Saves only the given client""" + nsmServerControl.clientSave(clientId) + +def clientToggleVisible(clientId:str): + """Works only if client announced itself with this feature""" + nsmServerControl.clientToggleVisible(clientId) + +def clientHideAll(): + nsmServerControl.allClientsHide() + +def clientShowAll(): + nsmServerControl.allClientsShow() + +def clientNameOverride(clientId:str, name:str): + """An argodejo-specific function that requires the client nsm-data in the session. + If nsm-data is not present this function will write nothing, not touch any data. + It will still send a callback to revert any GUI changes back to the original name. + + We accept empty string as a name to remove the name override + """ + nsmServerControl.clientNameOverride(clientId, name) + +def executableInSession(executable:str)->dict: + """Returns None if no client with this executable is in the session, + else returns a dict with its export-data. + If multiple clients with this exe are in the session only one is returned, whatever Python + thinks is good""" + for clientId, dic in nsmServerControl.internalState["clients"].items(): + if executable == dic["executable"]: + return dic + else: + return None + +#Global Datastructures, set in startEngine +nsmServerControl = None +eventLoop = None +sessionWatcher = None +callbacks = Callbacks() #This needs to be defined before startEngine() so a GUI can register its callbacks. The UI will then startEngine and wait to reveice the initial round of callbacks diff --git a/engine/config.py b/engine/config.py new file mode 100644 index 0000000..31149f1 --- /dev/null +++ b/engine/config.py @@ -0,0 +1,45 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +#Do not change these during runtime! + +METADATA={ + #The pretty name of this program. Used for NSM display and Jack client name + #Can contain everything a linux file/path supports. Never change this or it will break the + #session, making your file unable to load and destroying saved Jack connections. + "name" : "Argodejo", + + #Set this to the name the user types into a terminal. + #MUST be the same as the binary name as well as the name in configure. + #Program reports that as proc title so you can killall it by name. + #Should not contain spaces or special characters. We use this as save file extension as well + #to distinguish between compatible program versions. In basic programs this will just be e.g. + #patroneo. But in complex programs with a bright future it will be "laborejo1" "laborejo2" etc. + "shortName" : "argodejo", + + "version" : "1.0", + "year" : "2020", + "author" : "Laborejo Software Suite", + "url" : "https://www.laborejo.org/argodejo", + + "supportedLanguages" : {"German":"de.qm"}, + + #Show the About Dialog the first time the program starts up. This is the initial state for a + #new instance in NSM, not the saved state! Decide on how annoying it would be for every new + #instance to show about. Fluajho does not show it because you add it many times into a session. + #Patroneo does because its only added once. + "showAboutDialogFirstStart" : False, + + "preferredClients" : {"data":"nsm-data", "connections":"jackpatch", "proxy":"nsm-proxy"}, + + #Various strings for the README + #Extra whitespace will be stripped so we don't need to worry about docstring indentation + "description" : """ +Hello World + +""" + "\n" + """ +Foo Bar""", + + "dependencies" : "\n".join("* "+dep for dep in ("A Brain", "Linux" )), + +} diff --git a/engine/findprograms.py b/engine/findprograms.py new file mode 100644 index 0000000..3a85e5c --- /dev/null +++ b/engine/findprograms.py @@ -0,0 +1,230 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +The Non-Session-Manager by Jonathan Moore Liles : http://non.tuxfamily.org/nsm/ +With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) + +API documentation: http://non.tuxfamily.org/nsm/API.html + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), +more specifically its template base application. + +The Template Base 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") + +import pathlib +import configparser +import subprocess +import os +import stat + + +class SupportedProgramsDatabase(object): + """Find all binaries. Use all available resources: xdg desktop files, binary string seach in + executables, data bases, supplement with user choices. + + We generate the same format as configParser does with .desktop files as _sections dict. + + 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.4.1', + 'x-nsm-capable': 'true'} + + In case there is a file in PATH or database but has no .desktop we create our own entry with + missing data. + """ + + def __init__(self): + self.blacklist = ("nsmd", "non-daw", "carla") + self.morePrograms = ("thisdoesnotexisttest", "carla-rack", "carla-patchbay", "carla-jack-multi", "ardour5", "ardour6", "nsm-data", "nsm-jack") #only programs not found by buildCache_grepExecutablePaths because they are just shellscripts and do not contain /nsm/server/announce. + self.knownDesktopFiles = { #shortcuts to the correct desktop files. Reverse lookup binary->desktop creates false entries, for example ZynAddSubFx and Carla. + "zynaddsubfx": "zynaddsubfx-jack.desktop", #value will later get replaced with the .desktop entry + "carla-jack-multi" : "carla.desktop", + } + self.programs = [] #list of dicts. guaranteed keys: argodejoExec, name, argodejoFullPath. And probably others, like description and version. + self.nsmExecutables = set() #set of executables for fast membership, if a GUI wants to know if they are available. Needs to be build "manually" with self.programs. no auto-property for a list. at least we don't want to do the work. + #.build needs to be called from the api/GUI. + self.unfilteredExecutables = self.buildCache_unfilteredExecutables() #This doesn't take too long. we can start that every time. It will get updated in build as well. + #self.build() #fills self.programs and + + def buildCache_grepExecutablePaths(self): + """return a list of executable names in the path + Grep explained: + -s silent. No errors, eventhough subprocess uses stdout only + -R recursive with symlinks. We don't want to look in subdirs because that is not allowed by + PATH and nsm, but we want to follow symlinks + """ + result = [] + executablePaths = [pathlib.Path(p) for p in os.environ["PATH"].split(":")] + for path in executablePaths: + command = f"grep -iRsnl {path} -e /nsm/server/announce" + completedProcess = subprocess.run(command, capture_output=True, text=True, shell=True) + for fullPath in completedProcess.stdout.split(): + exe = pathlib.Path(fullPath).relative_to(path) + if not str(exe) in self.blacklist: + result.append((str(exe), str(fullPath))) + + for prg in self.morePrograms: + for path in executablePaths: + if pathlib.Path(path, prg).is_file(): + result.append((str(prg), str(pathlib.Path(path, prg)))) + break #inner loop + + return result + + def buildCache_DesktopEntries(self): + """Go through all dirs including subdirs""" + xdgPaths = ( + pathlib.Path("/usr/share/applications"), + pathlib.Path("/usr/local/share/applications"), + pathlib.Path(pathlib.Path.home(), ".local/share/applications"), + ) + + config = configparser.ConfigParser() + allDesktopEntries = [] + for basePath in xdgPaths: + for f in basePath.glob('**/*'): + if f.is_file() and f.suffix == ".desktop": + config.clear() + config.read(f) + entryDict = dict(config._sections["Desktop Entry"]) + if f.name == "zynaddsubfx-jack.desktop": + self.knownDesktopFiles["zynaddsubfx"] = entryDict + elif f.name == "carla.desktop": + self.knownDesktopFiles["carla-jack-multi"] = entryDict + #in any case: + allDesktopEntries.append(entryDict) #_sections 'DesktopEntry':{dictOfActualData) + return allDesktopEntries + + def loadPrograms(self, listOfDicts): + """Qt Settings will send us this""" + self.programs = listOfDicts + self.nsmExecutables = set(d["argodejoExec"] for d in self.programs) + + def build(self): + """Can be called at any time by the user to update after installing new programs""" + logger.info("Building launcher database. This might take a minute") + self.programs = self._build() + self.unfilteredExecutables = self.buildCache_unfilteredExecutables() + self.nsmExecutables = set(d["argodejoExec"] for d in self.programs) + self._buildWhitelist() + logger.info("Building launcher database done.") + + def _exeToDesktopEntry(self, exe:str)->dict: + """Assumes self.desktopEntries is up to date + Convert one exe (not full path!) to one dict entry. """ + if exe in self.knownDesktopFiles: #Shortcut through internal database + entry = self.knownDesktopFiles[exe] + else: #Reverse Search desktop files. + for entry in self.desktopEntries: + if "exec" in entry and exe.lower() in entry["exec"].lower(): + return entry + #else: #Foor loop ended. Did not find any matching desktop file + + return None + + + def _build(self): + self.executables = self.buildCache_grepExecutablePaths() + self.desktopEntries = self.buildCache_DesktopEntries() + + leftovers = set(self.executables) + matches = [] #list of dicts + for exe, fullPath in self.executables: + entry = self._exeToDesktopEntry(exe) + if entry: #Found match! + entry["argodejoFullPath"] = fullPath + #We don't want .desktop syntax like "qmidiarp %F" + entry["argodejoExec"] = exe + matches.append(entry) + + try: + leftovers.remove((exe,fullPath)) + except KeyError: + pass #Double entries like zyn-jack zyn-alsa etc. + + """ + if exe in self.knownDesktopFiles: #Shortcut through internal database + entry = self.knownDesktopFiles[exe] + else: #Reverse Search desktop files. + for entry in self.desktopEntries: + if "exec" in entry and exe.lower() in entry["exec"].lower(): + break #found entry. break inner loop to keep it + else: #Foor loop ended. Did not find any matching desktop file + #If we omit continue it will just write exe and fullPath in any desktop file. + continue + + #Found match! + entry["argodejoFullPath"] = fullPath + #We don't want .desktop syntax like "qmidiarp %F" + entry["argodejoExec"] = exe + matches.append(entry) + + try: + leftovers.remove((exe,fullPath)) + except KeyError: + pass #Double entries like zyn-jack zyn-alsa etc. + """ + + for exe,fullPath in leftovers: + pseudoEntry = {"name": exe.title(), "argodejoExec":exe, "argodejoFullPath":fullPath} + matches.append(pseudoEntry) + return matches + + def buildCache_unfilteredExecutables(self): + def isexe(path): + """executable by owner""" + return path.is_file() and stat.S_IXUSR & os.stat(path)[stat.ST_MODE] == 64 + + result = [] + executablePaths = [pathlib.Path(p) for p in os.environ["PATH"].split(":")] + for path in executablePaths: + result += [str(pathlib.Path(f).relative_to(path)) for f in path.glob("*") if isexe(f)] + + return sorted(list(set(result))) + + def _buildWhitelist(self): + """For reliable, fast and easy selection this is the whitelist. + It will be populated from a template-list of well-working clients and then all binaries not + in the path are filtered out. This can be presented to the user without worries.""" + #Assumes to be called only from self.build + startexecutables = set(("doesnotexist", "laborejo2", "patroneo", "vico", "fluajho", "carla-rack", "carla-patchbay", "carla-jack-multi", "ardour6", "drumkv1_jack", "synthv1_jack", "padthv1_jack", "samplv1_jack", "zynaddsubfx", "ADLplug", "OPNplug", "non-mixer", "non-sequencer", "non-timeline")) + for prog in self.programs: + prog["whitelist"] = prog["argodejoExec"] in startexecutables + + + """ + matches = [] + for exe in startexecutables: + entry = self._exeToDesktopEntry(exe) + if entry: #Found match! + #We don't want .desktop syntax like "qmidiarp %F" + entry["argodejoExec"] = exe + matches.append(entry) + return matches + """ + +programDatabase = SupportedProgramsDatabase() diff --git a/engine/nsmservercontrol.py b/engine/nsmservercontrol.py new file mode 100644 index 0000000..edcc5bf --- /dev/null +++ b/engine/nsmservercontrol.py @@ -0,0 +1,1603 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +The Non-Session-Manager by Jonathan Moore Liles : http://non.tuxfamily.org/nsm/ +With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) + +API documentation: http://non.tuxfamily.org/nsm/API.html + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ). + +The Template Base 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 +import struct +import socket +from os import getenv #to get NSM env var +from shutil import rmtree as shutilrmtree +from shutil import copytree as shutilcopytree +from urllib.parse import urlparse #to convert NSM env var +import subprocess +import atexit +import pathlib +import json +from datetime import datetime +from sys import exit as sysexit + +class _IncomingMessage(object): + """Representation of a parsed datagram representing an OSC message. + + An OSC message consists of an OSC Address Pattern followed by an OSC + Type Tag String followed by zero or more OSC Arguments. + """ + def __init__(self, dgram): + #NSM Broadcasts are bundles, but very simple ones. We only need to care about the single message it contains. + #Therefore we can strip the bundle prefix and handle it as normal message. + if b"#bundle" in dgram: + bundlePrefix, singleMessage = dgram.split(b"/", maxsplit=1) + dgram = b"/" + singleMessage # / eaten by split + + self.LENGTH = 4 #32 bit + self._dgram = dgram + self._parameters = [] + self.parse_datagram() + + def get_int(self, dgram, start_index): + """Get a 32-bit big-endian two's complement integer from the datagram. + + Args: + dgram: A datagram packet. + start_index: An index where the integer starts in the datagram. + + Returns: + A tuple containing the integer and the new end index. + + Raises: + ValueError if the datagram could not be parsed. + """ + try: + if len(dgram[start_index:]) < self.LENGTH: + raise ValueError('Datagram is too short') + return ( + struct.unpack('>i', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH) + except (struct.error, TypeError) as e: + raise ValueError('Could not parse datagram %s' % e) + + def get_string(self, dgram, start_index): + """Get a python string from the datagram, starting at pos start_index. + + We receive always the full string, but handle only the part from the start_index internally. + In the end return the offset so it can be added to the index for the next parameter. + Each subsequent call handles less of the same string, starting further to the right. + + According to the specifications, a string is: + "A sequence of non-null ASCII characters followed by a null, + followed by 0-3 additional null characters to make the total number + of bits a multiple of 32". + + Args: + dgram: A datagram packet. + start_index: An index where the string starts in the datagram. + + Returns: + A tuple containing the string and the new end index. + + Raises: + ValueError if the datagram could not be parsed. + """ + #First test for empty string, which is nothing, followed by a terminating \x00 padded by three additional \x00. + if dgram[start_index:].startswith(b"\x00\x00\x00\x00"): + return "", start_index + 4 + + #Otherwise we have a non-empty string that must follow the rules of the docstring. + + offset = 0 + try: + while dgram[start_index + offset] != 0: + offset += 1 + if offset == 0: + raise ValueError('OSC string cannot begin with a null byte: %s' % dgram[start_index:]) + # Align to a byte word. + if (offset) % self.LENGTH == 0: + offset += self.LENGTH + else: + offset += (-offset % self.LENGTH) + # Python slices do not raise an IndexError past the last index, + # do it ourselves. + if offset > len(dgram[start_index:]): + raise ValueError('Datagram is too short') + data_str = dgram[start_index:start_index + offset] + return data_str.replace(b'\x00', b'').decode('utf-8'), start_index + offset + except IndexError as ie: + raise ValueError('Could not parse datagram %s' % ie) + except TypeError as te: + raise ValueError('Could not parse datagram %s' % te) + + def get_float(self, dgram, start_index): + """Get a 32-bit big-endian IEEE 754 floating point number from the datagram. + + Args: + dgram: A datagram packet. + start_index: An index where the float starts in the datagram. + + Returns: + A tuple containing the float and the new end index. + + Raises: + ValueError if the datagram could not be parsed. + """ + try: + return (struct.unpack('>f', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH) + except (struct.error, TypeError) as e: + raise ValueError('Could not parse datagram %s' % e) + + def parse_datagram(self): + try: + self._address_regexp, index = self.get_string(self._dgram, 0) + if not self._dgram[index:]: + # No params is legit, just return now. + return + + # Get the parameters types. + type_tag, index = self.get_string(self._dgram, index) + if type_tag.startswith(','): + type_tag = type_tag[1:] + + # Parse each parameter given its type. + for param in type_tag: + if param == "i": # Integer. + val, index = self.get_int(self._dgram, index) + elif param == "f": # Float. + val, index = self.get_float(self._dgram, index) + elif param == "s": # String. + val, index = self.get_string(self._dgram, index) + else: + logger.warning("Unhandled parameter type: {0}".format(param)) + continue + self._parameters.append(val) + except ValueError as pe: + #raise ValueError('Found incorrect datagram, ignoring it', pe) + # Raising an error is not ignoring it! + logger.warning("Found incorrect datagram, ignoring it. {}".format(pe)) + + @property + def oscpath(self): + """Returns the OSC address regular expression.""" + return self._address_regexp + + @staticmethod + def dgram_is_message(dgram): + """Returns whether this datagram starts as an OSC message.""" + return dgram.startswith(b'/') + + @property + def size(self): + """Returns the length of the datagram for this message.""" + return len(self._dgram) + + @property + def dgram(self): + """Returns the datagram from which this message was built.""" + return self._dgram + + @property + def params(self): + """Convenience method for list(self) to get the list of parameters.""" + return list(self) + + def __iter__(self): + """Returns an iterator over the parameters of this message.""" + return iter(self._parameters) + +class _OutgoingMessage(object): + def __init__(self, oscpath): + self.LENGTH = 4 #32 bit + self.oscpath = oscpath + self._args = [] + + def write_string(self, val): + dgram = val.encode('utf-8') + diff = self.LENGTH - (len(dgram) % self.LENGTH) + dgram += (b'\x00' * diff) + return dgram + + def write_int(self, val): + return struct.pack('>i', val) + + def write_float(self, val): + return struct.pack('>f', val) + + def add_arg(self, argument): + t = {str:"s", int:"i", float:"f"}[type(argument)] + self._args.append((t, argument)) + + def build(self): + dgram = b'' + + #OSC Path + dgram += self.write_string(self.oscpath) + + if not self._args: + dgram += self.write_string(',') + return dgram + + # Write the parameters. + arg_types = "".join([arg[0] for arg in self._args]) + dgram += self.write_string(',' + arg_types) + for arg_type, value in self._args: + f = {"s":self.write_string, "i":self.write_int, "f":self.write_float}[arg_type] + dgram += f(value) + return dgram + +class NsmServerControl(object): + """ + The ServerControl can be started in three modes, regarding nsmd. + We expect that starting our own nsmd will be the majority of cases. + SessionRoot parameter is only honored if we start nsmd ourselves. + + Ascending lookup priority: + 1) Default is to start our own nsmd. A single-instance watcher will prevent multiple programs + on the same system. + 2) When $NSM_URL is found as environment we will connect to that nsmd. + 3) When hostname and portnumber are given explicitely as instance variables we will first test + if a server is running at that URL, if not we will start our own with these parameters. + + + This is not only a pure implemenation of the protocol. + It is extended by us reacting to and storing incoming data. This data can be interpreted + and enhanced by looking at the session dir ourselves. However, we don't do anything that + is not possible by the original nsmd + human interaction. + 100% Compatibility is the highest priority. + + The big problems are the async nature of communication, message come out of order or interleaved, + and nsm is not consistent in its usage of osc-paths. For example it starts listing sessions + with /nsm/gui/server/message, but sends the content with /reply [/nsm/server/list, nsmSessionName] and + then ends it with /nsm/server/list [0, Done] (no reply!). So three message types, three callbacks + for one logically connected process. + + To update our internal session information we therefore need to split the functionality into + severall seemingly unconnected callbacks and you need to know how the protocol works to actually + know the order of operations. Switch logging to info to learn more. + + We have a mix between NSM callbacks and our own functions. + Most important there is a watchdog that looks at the session directory and creates its own + callbacks if something changes. + A typical operation, say sessionDelete or sessionCopy looks like this: + * Ask (blocking) nsmd for a current list of sessions, update our internal state + * Perform a file operation, like copy or delete or lift a lock + * Let our watchdog discover the changes in the file system and trigger another (non-blocking) + request for a list of sessions to adjust our internal state to reality. + Granted, we could just call our blocking query again at the end, but we would still need to + let the watchdog run for operations that the user does with a filemanager, which would end up + in redundant calls. Bottom line: _updateSessionListBlocking is called at the beginning of a + function, but not at the end. + + Docs: + http://non.tuxfamily.org/nsm/ + http://non.tuxfamily.org/wiki/Non%20Session%20Manager + http://non.tuxfamily.org/wiki/ApplicationsSupportingNsm + http://non.tuxfamily.org/nsm/API.html + """ + + def __init__(self, sessionOpenReadyHook, sessionOpenLoadingHook, sessionClosedHook, clientStatusHook, singleInstanceActivateWindowHook, dataClientNamesHook, dataClientDescriptionHook, parameterNsmOSCUrl=None, sessionRoot=None, useCallbacks=True): + """If useCallbacks is False you will see every message in the log. + This is just a development mode to see all messages, unfiltered. + + Normally we have special hook functions that save and interpret data, so they don't + show in the logs""" + + + self._queue = list() #Incoming OSC messages are buffered here. + + #Status variables that are set by our callbacks + self.internalState = { + "sessions" : set(), #nsmSessionNames:str . We use set for unqiue, just in case. But we also clear that on /nsm/gui/server/message ['Listing sessions'] to handle deleted sessions + "currentSession" : None, + "port" : None, #Our GUI port + "serverPort" : None, #nsmd port + "nsmUrl" : None, #the environment variable + "clients" : {}, #clientId:dict see self._initializeEmptyClient . Gets replaced with a new dict instance on session changes. + "broadcasts" : [], #in the order they appeared + "datastorage" : None, #URL, if present in the session + } + + self.dataStorage = None #Becomes DataStorage() every time a datastorage client does a broadcast announce. + self._addToNextSession = [] #A list of executables in PATH. Filled by new, waits for reply that session is created and then will send clientNew and clear the list. + + #Hooks for api callbacks + self.sessionOpenReadyHook = sessionOpenReadyHook #nsmSessionName as parameter + self.sessionOpenLoadingHook = sessionOpenLoadingHook #nsmSessionName as parameter + self.sessionClosedHook = sessionClosedHook #no parameter. This is also "choose a session" mode + self.clientStatusHook = clientStatusHook #all client status is done via this single hook. GUIs need to check if they already know the client or not. + self.dataClientNamesHook = dataClientNamesHook + self.dataClientDescriptionHook = dataClientDescriptionHook + self.singleInstanceActivateWindowHook = singleInstanceActivateWindowHook #added to self.processSingleInstance() to listen for a message from another wannabe-instance + + if useCallbacks: + self.callbacks = { + "/nsm/gui/session/name" : self._reactCallback_activeSessionChanged, + #"/nsm/gui/session/root" #Session root is an active blocking call in init + "/nsm/gui/client/label" : self._reactCallback_ClientLabelChanged, + "/nsm/gui/client/new" : self._reactCallback_ClientNew, + "/nsm/gui/session/session" : self._reactCallback_SessionSession, + "/nsm/gui/client/status" : self._reactCallback_statusChanged, #handles multiple status keywords + "/reply" : self._reactCallback_reply, #handles multiple replies + "/error" : self._reactCallback_error, + "/nsm/gui/client/has_optional_gui" : self._reactCallback_clientHasOptionalGui, + "/nsm/gui/client/gui_visible" : self._reactCallback_clientGuiVisible, + "/nsm/gui/client/pid" : self._reactCallback_clientPid, + "/nsm/gui/client/dirty" : self._reactCallback_clientDirty, + "/nsm/gui/server/message" : self._reactCallback_serverMessage, + "/nsm/gui/gui_announce" : self._reactCallback_guiAnnounce, + "/nsm/server/list" : self._reactCallback_serverList, + "/nsm/server/broadcast" : self._reactCallback_broadcast, + } + else: #This is just a development mode to see all messages, unfiltered + self.callbacks = set() #empty set is easiest to check + + #Networking and Init for our control part, not for the server + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #internet, udp + self.sock.bind(('', 0)) #pick a free port on localhost. + self.sock.setblocking(False) + self.internalState["port"] = self.sock.getsockname()[1] #only happens once, ports don't change during runtime + #self.sock.close() Do not close, this runs until the end of the program + + ###Testing of existing servers, starting and connecting + + #First handle the NSM URL, or generate on. + #self.nsmOSCUrl must be a tuple compatible to the result of urlparse. (hostname, port) + self.singleInstanceSocket = None + if parameterNsmOSCUrl: + o = urlparse(parameterNsmOSCUrl) + self.nsmOSCUrl = (o.hostname, o.port) + else: + envResult = self._getNsmOSCUrlFromEnvironment() + if envResult: + self.nsmOSCUrl = envResult + else: + #This is the default case. User just starts the GUI. The other modes are concious decisions to either start with URL as parameter or in an NSM environment. + #But now we need to test if the user accidentaly opened a second GUI, which would start a second server. + self._setupAndTestForSingleInstance() #This might quit the whole program and we will never see the next line. + self.nsmOSCUrl = self._generateFreeNsmOSCUrl() + + assert self.nsmOSCUrl + self.internalState["serverPort"] = self.nsmOSCUrl[1] #only happens once, ports don't change during runtime + self.internalState["nsmUrl"] = f"osc.udp://{self.nsmOSCUrl[0]}:{self.nsmOSCUrl[1]}/" #only happens once, ports don't change during runtime + + #Then check if a server is running there. If not start one. + self.ourOwnServer = None #Might become a subprocess handle + if self._isNsmdRunning(self.nsmOSCUrl): + #serverport = self.nsmOSCUrl[1] + #No further action required. GUI announce below this testing. + pass + else: + self._startNsmdOurselves(sessionRoot) #Session root can be a commandline parameter we forward to the server if we start it ourselves. + assert type(self.ourOwnServer) is subprocess.Popen, (self.ourOwnServer, type(self.ourOwnServer)) + + #Wait for the server, or test if it is reacting. + self._waitForPingResponseBlocking() + logger.info("nsmd is ready @ {}".format(self.nsmOSCUrl)) + + #Tell nsmd that we are a GUI and want to receive general messages async, not only after we request something + self.gui_announce() #Triggers "hi" and session root + self.sessionRoot = self._waitForSessionRootBlocking() + self.internalState["sessionRoot"] = self.sessionRoot + + atexit.register(self.quit) #mostly does stuff when we started nsmd ourself + + self._receiverActive = True + #Now an external event loop can add self.process + + #Internal Methods + def _setupAndTestForSingleInstance(self): + """on program startup trigger this if there is already another instance of us running. + This socket is only + """ + self.singleInstanceSocket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + logger.info("Testing if another non-specific Argodejo is running.") + try: + ## Create an abstract socket, by prefixing it with null. + # this relies on a feature only in linux, when current process quits, the + # socket will be deleted. + self.singleInstanceSocket.bind('\0' + "argodejo") + self.singleInstanceSocket.listen(1) + self.singleInstanceSocket.setblocking(False) + logger.info("No other non-specific Argodejo found. Starting GUI") + #Continue in self.processSingleInstance() + return True + except socket.error: + logger.error("GUI for this nsmd server already running. Informing the existing application to show itself.") + self.singleInstanceSocket.connect('\0' + "argodejo") + self.singleInstanceSocket.send("argodejoactivate".encode()); + self.singleInstanceSocket.close() + sysexit(1) #triggers atexit + #print ("not executed") + return False + + + def processSingleInstance(self): + """Tests our unix socket for an incoming signal. + if received forward to the engine->gui + Can be added to a slower event loop, so it is not in self.process""" + if self.singleInstanceSocket: + try: + connection, client_address = self.singleInstanceSocket.accept() #This blocks and waits for a message + incoming = connection.recv(1024) + if incoming and incoming == b"argodejoactivate": + self.singleInstanceActivateWindowHook() + except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not. In fact: this happens when in non-blocking mode. + pass + except socket.timeout: + pass + + def _setPause(self, state:bool): + """Set both the socket and the thread into waiting mode or not. + With this we can wait for answers until we resume async operation""" + if state: + self.sock.setblocking(True) #explicitly wait. + self.sock.settimeout(0.5) + self._receiverActive = False + logger.info("Suspending receiving async mode.") + else: + self.sock.setblocking(False) + self._receiverActive = True + logger.info("Resuming receiving async mode.") + + def process(self): + """Use this in an external event loop""" + if self._receiverActive: + while True: + try: + data, addr = self.sock.recvfrom(1024) + msg = _IncomingMessage(data) + if msg: + self._queue.append(msg) + except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not. + break + except socket.timeout: + break + + for msg in self._queue: + if msg.oscpath in self.callbacks: + self.callbacks[msg.oscpath](msg.params) + else: + logger.warning(f"Unhandled message with path {msg.oscpath} and parameters {msg.params}") + self._queue.clear() + + def _getNsmOSCUrlFromEnvironment(self): + """Return the nsm osc url or None""" + nsmOSCUrl = getenv("NSM_URL") + if not nsmOSCUrl: + return None + else: + #osc.udp://hostname:portnumber/ + o = urlparse(nsmOSCUrl) + return o.hostname, o.port + + def _generateFreeNsmOSCUrl(self): + #Instead of reading out the NSM port we get a free port ourselves and set up nsmd with that + tempServerSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #internet, udp + tempServerSock.bind(('', 0)) #pick a free port on localhost. + address, tempServerSockPort = tempServerSock.getsockname() + tempServerSock.close() #We need to close it because nsmd will open it right away. + nsmOSCUrl = ("0.0.0.0", tempServerSockPort) #compatible to result of urlparse + logger.info("Generated our own free NSM_URL to start a server @ {}".format(nsmOSCUrl)) + return nsmOSCUrl + + def _isNsmdRunning(self, nsmOSCUrl): + """Test if the port is open or not""" + logger.info(f"Testing if a server is running @ {nsmOSCUrl}") + hostname, port = nsmOSCUrl + tempServerSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #internet, udp + try: + tempServerSock.bind((hostname, port)) + logger.info(f"No external nsmd found (we tested if port is closed) @ {nsmOSCUrl}") + return False + except: + logger.info(f"External nsmd found (we tested if port is closed) @ {nsmOSCUrl}") + return True + finally: + tempServerSock.close() + + def _startNsmdOurselves(self, sessionRoot:str): + assert self.nsmOSCUrl + hostname, port = self.nsmOSCUrl + if sessionRoot: + self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port)], "--session-root", sessionRoot) + else: + self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port)]) + + + + #Category: Better safe than sorry + def _waitForPingResponseBlocking(self): + """Only used to test if the nsm server is ready. + Uses timeout as waiting time. + + This cannot be used after our thread with run_receivingServer has started because + the ping reply will be received by this thread instead. + """ + self._setPause(True) + self.sock.settimeout(0.01) + + logger.info("Sending /osc/ping") + out_msg = _OutgoingMessage("/osc/ping") + + while True: + self.sock.sendto(out_msg.build(), self.nsmOSCUrl) #we need to send multiple times. If the server is not ready it can't receive the ping :) + try: + data, addr = self.sock.recvfrom(1024) + msg = _IncomingMessage(data) + break + except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not. + continue + except socket.timeout: + continue + + if msg.oscpath == "/reply" and msg.params[0] == "/osc/ping": + logger.info("Got ping response") + return True + else: + logger.error(f"Waiting for ping, but got path: {msg.oscpath} with {msg.params}. Adding to queue for later. If the server got started anyway and is reacting to your commands, it is fine for now.") + self._queue.append(msg) + + self._setPause(False) + + def _waitForSessionRootBlocking(self): + """Arrives after GUI Announce 'hi' + There is only one session root """ + logger.info("Waiting for session root message in blocking mode") + self._setPause(True) + ready = False + while not ready: + data, addr = self.sock.recvfrom(1024) + msg = _IncomingMessage(data) + + if msg.oscpath == "/nsm/gui/session/root": + sessionRoot = msg.params[0] + ready = True + else: + self._queue.append(msg) + + logger.info(f"Session root directory is {sessionRoot}") + self._setPause(False) + return sessionRoot + + #General Commands + def send(self, arg): + """ + Intended for a text input / command line interface. + Sends anything to nsmd, separated by semicolon. First part is the message address, + the rest are string-parameters.""" + args = arg.split() + msg = _OutgoingMessage(args[0]) + for p in args[1:]: + msg.add_arg(p) + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def gui_announce(self): + msg = _OutgoingMessage("/nsm/gui/gui_announce") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def ping(self): + msg = _OutgoingMessage("/osc/ping") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def list(self): + msg = _OutgoingMessage("/nsm/server/list") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def _updateSessionListBlocking(self): + """To ensure correct data on session operations we manage ourselves, + like copy, rename and delete. + Ask nsmd for projects in session root and update our internal state. + + This will wait for an answer and block all other operations. + + First is /nsm/gui/server/message ['Listing sessions'] + Then session names come one reply at a time such as /reply ['/nsm/server/list', 'test3'] + Finally /nsm/server/list [0, 'Done.'] , not a reply + """ + logger.info("Requesting project list from session server in blocking mode") + self._setPause(True) + + msg = _OutgoingMessage("/nsm/server/list") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + #Wait for /reply + ready = False + while True: + try: + data, addr = self.sock.recvfrom(1024) + except socket.timeout: + continue + msg = _IncomingMessage(data) + if not ready and msg.oscpath == "/nsm/gui/server/message" and msg.params == ["Listing sessions"]: + self.internalState["sessions"].clear() # new clients are added as /reply /nsm/server/list callbacks + ready = True + else: + if len(msg.params) != 2: + logger.warning(f"Expected project but got path {msg.oscpath} with {msg.params}. Adding to queue for later.") + self._queue.append(msg) + continue + elif msg.oscpath == "/reply" and msg.params[0] == "/nsm/server/list": + #/reply ['/nsm/server/list', 'test3'] + self.internalState["sessions"].add(msg.params[1]) + logger.info(f"Received session name: {msg.params[1]}") + elif msg.params[0] == 0 and msg.params[1] == "Done.": + break + else: + logger.warning(f"Expected project but got path {msg.oscpath} with {msg.params}. Adding to queue for later.") + self._queue.append(msg) + continue + + self._setPause(False) + + def quit(self): + """Called through atexit. + Thanks to start.py sys exception hook this will also trigger on PyQt crash""" + if self.ourOwnServer: + msg = _OutgoingMessage("/nsm/server/quit") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + returncode = self.ourOwnServer.wait() + logger.info("Stopped our own server with return code {}".format(returncode)) + + def broadcast(self, path:str, arguments:list): + """/nsm/server/broadcast s:path [arguments...] + + http://non.tuxfamily.org/nsm/API.html 1.2.7.1 + /nsm/server/broadcast s:path [arguments...] + /nsm/server/broadcast /tempomap/update "0,120,4/4:12351234,240,4/4" + + All clients except the sender recive: + /tempomap/update "0,120,4/4:12351234,240,4/4" + """ + logger.info(f"Sending broadcast with path {path} and args {arguments}") + message = _OutgoingMessage("/nsm/server/broadcast") + message.add_arg(path) + for arg in arguments: + message.add_arg(arg) #type autodetect + self.sock.sendto(message.build(), self.nsmOSCUrl) + + #Primarily Without Session + def open(self, nsmSessionName:str): + if nsmSessionName in self.internalState["sessions"]: + msg = _OutgoingMessage("/nsm/server/open") + msg.add_arg(nsmSessionName) #s:project_name + self.sock.sendto(msg.build(), self.nsmOSCUrl) + else: + logger.warning(f"Session {nsmSessionName} not found. Not forwarding to nsmd.") + + def new(self, newName:str, startClients:list=[])->str: + """Saves the current session and creates a new session. + Only works if dir does not exist yet. + """ + basePath = pathlib.Path(self.sessionRoot, newName) + if basePath.exists(): + return None + + self._addToNextSession = startClients + + msg = _OutgoingMessage("/nsm/server/new") + msg.add_arg(newName) #s:project_name + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + #Only with Current Session + def save(self): + msg = _OutgoingMessage("/nsm/server/save") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def close(self, blocking=False): + if not blocking: + msg = _OutgoingMessage("/nsm/server/close") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + else: + msg = _OutgoingMessage("/nsm/server/close") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + #Drive the process loop ourselves. This will still trigger updates but the mainloop will wait. + while self.internalState["currentSession"]: + self.process() + + def abort(self, blocking=False): + if not blocking: + msg = _OutgoingMessage("/nsm/server/abort") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + else: + msg = _OutgoingMessage("/nsm/server/abort") + self.sock.sendto(msg.build(), self.nsmOSCUrl) + #Drive the process loop ourselves. This will still trigger updates but the mainloop will wait. + while self.internalState["currentSession"]: + self.process() + + def duplicate(self, newName:str)->str: + """Saves the current session and creates a new session. + Requires an open session and uses nsmd to do the work. + If you want to do copy of any session use our owns + self.sessionCopy""" + msg = _OutgoingMessage("/nsm/server/duplicate") + msg.add_arg(newName) #s:project_name + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + #Client Commands for Loaded Session + def clientAdd(self, executableName:str): + """Adds a client to the current session. + executable must be in $PATH. + + We do not trust NSM to perform the right checks. It will add an empty path or wrong + path. + """ + if not pathlib.Path(executableName).name == executableName: + logger.warning(f"{executableName} must be just an executable file in your $PATH. We expected: {pathlib.Path(executableName).name} . We will not ask nsmd to add it as client") + return False + + allPaths = getenv("PATH") + assert allPaths, allPaths + binaryPaths = allPaths.split(":") #TODO: There is a corner case that NSMD runs in a different $PATH environment. + executableInPath = any(pathlib.Path(bp, executableName).is_file() for bp in binaryPaths) + if executableInPath: + msg = _OutgoingMessage("/nsm/server/add") + msg.add_arg(executableName) #s:executable_name + self.sock.sendto(msg.build(), self.nsmOSCUrl) + return True + else: + logger.warning("Executable {} not found. We will not ask nsmd to add it as client".format(executableName)) + return False + + def clientStop(self, clientId:str): + msg = _OutgoingMessage("/nsm/gui/client/stop") + msg.add_arg(clientId) #s:clientId + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def clientResume(self, clientId:str): + """Opposite of clientStop""" + msg = _OutgoingMessage("/nsm/gui/client/resume") + msg.add_arg(clientId) #s:clientId + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def clientRemove(self, clientId:str): + """Client needs to be stopped already. We will do that and wait for an answer. + Remove from the session. Will not delete the save-files, but make them inaccesible""" + #We have a blocking operation in here so we need to be extra cautios that the client exists. + if not clientId in self.internalState["clients"]: + return False + + self.clientStop(clientId) #We need to wait for an answer. + #Drive the process loop ourselves. This will still trigger updates but the mainloop will wait. + logger.info(f"Waiting for {clientId} to be status 'stopped'") + while not self.internalState["clients"][clientId]["lastStatus"] == "stopped": + self.process() + + msg = _OutgoingMessage("/nsm/gui/client/remove") + msg.add_arg(clientId) #s:clientId + self.sock.sendto(msg.build(), self.nsmOSCUrl) + #Flood lazy lagging nsmd until it removed the client. + #We will receive a few -10 "No such client." errors but that is ok. + while True: + if not clientId in self.internalState["clients"]: + break + if self.internalState["clients"][clientId]["lastStatus"] == "removed": + break + self.sock.sendto(msg.build(), self.nsmOSCUrl) + self.process() + + def clientSave(self, clientId:str): + """Saves only the given client""" + msg = _OutgoingMessage("/nsm/gui/client/save") + msg.add_arg(clientId) #s:clientId + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def clientHide(self, clientId:str): + """Hides the client. Works only if client announced itself with this feature""" + msg = _OutgoingMessage("/nsm/gui/client/hide_optional_gui") + msg.add_arg(clientId) #s:clientId + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + def clientShow(self, clientId:str): + """Hides the client. Works only if client announced itself with this feature""" + msg = _OutgoingMessage("/nsm/gui/client/show_optional_gui") + msg.add_arg(clientId) #s:clientId + self.sock.sendto(msg.build(), self.nsmOSCUrl) + + #Callbacks + def _reactCallback_guiAnnounce(self, parameters:list): + """Acknowledge""" + assert parameters == ["hi"], parameters + logger.info("We got acknowledged as current nsmd GUI.") + + def _reactCallback_error(self, parameters:list): + logger.error(parameters) + + def _reactCallback_reply(self, parameters:list): + """This is a difficult function because replies arrive for many unrelated things, like + status. We do our best to send all replies on the right way""" + success = False + l = len(parameters) + + if l == 2: + originalMessage, data = parameters + logger.info(f"Got reply for {originalMessage} with {data}") + reply = { + "/nsm/server/list" : self._reactReply_nsmServerList, + "/nsm/server/new" : self._reactReply_nsmServerNew, + "/nsm/server/close" : self._reactReply_nsmServerClose, + "/nsm/server/open" : self._reactReply_nsmServerOpen, + "/nsm/server/save" : self._reactReply_nsmServerSave, + "/nsm/server/abort" : self._reactReply_nsmServerAbort, + "/nsm/server/duplicate" : self._reactReply_nsmServerDuplicate, + } + + if originalMessage in reply: + reply[originalMessage](data) + success = True + elif l == 3: + originalMessage, errorCode, answer = parameters + logger.info(f"Got reply for {originalMessage} with code {errorCode} saying {answer}") + if originalMessage == "/nsm/server/add": + assert errorCode == 0, parameters + self._reactReply_nsmServerAdd(answer) + success = True + + elif l == 1: + singleMessage = parameters[0] + """For unknown reasons these replies do not repeat the originalMessage""" + if singleMessage == "/osc/ping": + logger.info(singleMessage) + success = True + elif singleMessage == "Client removed.": + self._reactReply_nsmServerRemoved() + success = True + elif singleMessage == "Client stopped.": + self._reactReply_nsmServerStopped() + success = True + + + #After all these reactions and checks the function will eventually return here. + if not success: + raise NotImplementedError(parameters) + + def _reactCallback_serverMessage(self, parameters:list): + """Messages are normally harmless and uninteresting. + Howerver, we need to use some of them for actual tasks. + In opposite to reply and status this all go in our function for now, until refactoring""" + if parameters == ["Listing session"]: + #this feels bad! A simple message is not a reliable state token and could change in the future. + #we cannot put that into our own /list outgoing message because other actions like "new" also trigger this callback + self.internalState["sessions"].clear() # new clients are added as /reply /nsm/server/list callbacks + + if parameters[0].startswith("Opening session"): + #This gets send only when an existing session starts loading. It will not trigger on new sessions, be it really new or duplicate. + #e.g. /nsm/gui/server/message ["Opening session FOO"] + nsmSessionName = parameters[0].replace("Opening session ", "") + logger.info(f"Starting to load clients of session: {nsmSessionName}") + self.sessionOpenLoadingHook(self.sessionAsDict(nsmSessionName)) #notify the api->UI + else: + logger.info("/nsm/gui/server/message " + repr(parameters)) + + + def _reactCallback_broadcast(self, parameters:list): + """We have nothing to do with broadcast. But we save them, so they can be shown on request + + parameters[0] is an osc path:str without naming constraints + the rest is a list of arguments. + """ + logger.info(f"Received broadcast. Saving in internal state: {parameters}") + self.internalState["broadcasts"].append(parameters) + + #Our little trick. We know and like some clients better than others. + #If we detect our own data-storage we remember our friends. + #It is possible that another datastorage broadcasts, then we overwrite the URL. + if parameters and parameters[0] == "/argodejo/datastorage/announce": + path, clientId, messageSizeLimit, url = parameters + assert "osc.udp" in url + logger.info(f"Got announce from argodejo datastorage clientId {clientId} @ {url}") + o = urlparse(url) + self.dataStorage = DataStorage(self, clientId, messageSizeLimit, (o.hostname, o.port), self.sock) + + def _reactCallback_serverList(self, parameters:list): + """This finalizes a new session list. Here we send new data to the GUI etc.""" + l = len(parameters) + if l == 2: + errorCode, message = parameters + assert errorCode == 0, errorCode + assert message == "Done.", message #don't miss the dot after Done + logger.info("/nsm/server/list is done and has transmitted all available sessions to us") + else: + raise NotImplementedError(parameters) + + + def _reactCallback_activeSessionChanged(self, parameters:list): + """This is called when the session has already changed. + Shortly before we receive /nsm/gui/session/session which indicates the attempt to create, + I guess! :) + + If you want to react to the attempt to open a session you need to use /nsm/gui/server/message ["Opening session FOO"] + OR creating a new session, after which nsmd will open that session without a message. + + Empty string is "No session" or "Choose A Session" mode. + """ + l = len(parameters) + if l == 2: + shortName, nsmSessionName = parameters + if not shortName and not nsmSessionName: #No session loaded. We are in session-choosing mode. + self.internalState["currentSession"] = None + self.sessionClosedHook() + else: + nsmSessionName = nsmSessionName.lstrip("/") + logger.info(f"Current Session changed. We are now {shortName} under {nsmSessionName}") + self.internalState["currentSession"] = nsmSessionName + #This is after the session, received after all programs have loaded. + #We have a counterpart as message reaction that signals the attempt to load. + self.sessionOpenReadyHook(self.sessionAsDict(nsmSessionName)) #notify the api->UI + for autoClientExecutableInPath in self._addToNextSession: + self.clientAdd(autoClientExecutableInPath) + self._addToNextSession = [] #reset + elif l == 0: #Another way of "no session". + self.internalState["currentSession"] = None + self.sessionClosedHook() + else: + raise NotImplementedError(parameters) + + def _initializeEmptyClient(self, clientId:str): + """NSM reuses signals. It is quite possible that this will be called multiple times, + e.g. after opening a session""" + if clientId in self.internalState["clients"]: + return + logger.info(f"Creating new internal entry for client {clientId}") + client = { + "clientId":clientId, #for convenience, included internally as well + "executable":None, #For dumb clients this is the same as reportedName. + "dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this. + "reportedName":None, #str . The reported name is first the executable name, for status started. But for NSM clients it gets replaced with a reported name. + "label":None, #str + "lastStatus":None, #str + "statusHistory":[], #list + "hasOptionalGUI": False, #bool + "visible": None, # bool + "dirty": None, # bool + } + self.internalState["clients"][clientId] = client + + def _setClientData(self, clientId:str, parameter:str, value): + if clientId in self.internalState["clients"]: + self.internalState["clients"][clientId][parameter] = value + return True + else: + logger.warning(f"Client {clientId} not found in internal status storage. If the session was just closed this is most likely a known race condition. Everything is fine in this case.") + return False + + def _reactCallback_ClientLabelChanged(self, parameters:list): + """osc->add_method( "/nsm/gui/client/label", "ss", osc_handler, osc, "path,display_name" ); + """ + l = len(parameters) + if l == 2: + clientId, label = parameters + logger.info(f"Label for client {clientId} changed to {label}") + self._setClientData(clientId, "label", label) + else: + raise NotImplementedError(parameters) + + def _reactCallback_clientPid(self, parameters:list): + clientId, pid = parameters + self._setClientData(clientId, "pid", pid) + + def _reactCallback_SessionSession(self, parameters:list): + """This is received only when a new session gets created and followed by + /nsm/gui/client/new and then a reply for + /reply /nsm/server/new Session created""" + #This is the counterpart to Message "Opening Session", but for really new or freshly duplicated session. + logger.info(f"Attempt to create session: {parameters}") + self.sessionOpenLoadingHook(self.sessionAsDict(parameters[0])) #notify the api->UI + + def _reactCallback_ClientNew(self, parameters:list): + """/nsm/gui/client/new ['nBAVO', 'jackpatch'] + This is both add client or open. + It appears in the session. + + And the message actually comes twice. Once when you add a client, then parameters + will contain the executable name. If the client reports itself as NSM compatible through + announce we will also get the Open message through this function. + Then the name changes from executableName to a reportedName, which will remain for the rest + of the session. + A loaded session will directly start with the reported/announced name. + """ + l = len(parameters) + if l == 2: + clientId, executableName = parameters + if not clientId in self.internalState["clients"]: + self._initializeEmptyClient(clientId) + self._setClientData(clientId, "executable", executableName) + logger.info(f"Client started {executableName}:{clientId}") + else: + self._setClientData(clientId, "reportedName", executableName) + logger.info(f"Client upgraded to NSM-compatible: {executableName}:{clientId}") + self.clientStatusHook(self.internalState["clients"][clientId]) + else: + raise NotImplementedError(parameters) + + def _reactCallback_clientDirty(self, parameters:list): + """/nsm/gui/client/dirty ['nMAJH', 1] + """ + l = len(parameters) + if l == 2: + clientId, dirty = parameters + dirty = bool(dirty) + self._setClientData(clientId, "dirty", dirty) + logger.info(f"Client {clientId} save status dirty is now: {dirty}") + self.clientStatusHook(self.internalState["clients"][clientId]) + else: + raise NotImplementedError(parameters) + + def _reactCallback_clientGuiVisible(self, parameters:list): + """/nsm/gui/client/gui_visible ['nMAJH', 0] + """ + l = len(parameters) + if l == 2: + clientId, visible = parameters + visible = bool(visible) + self._setClientData(clientId, "visible", visible) + logger.info(f"Client {clientId} visibility is now: {visible}") + self.clientStatusHook(self.internalState["clients"][clientId]) + else: + raise NotImplementedError(parameters) + + def _reactCallback_clientHasOptionalGui(self, parameters:list): + """/nsm/gui/client/has_optional_gui ['nFDBK'] + nsmd sends us this as reaction to a clients announce capabilities list + """ + l = len(parameters) + if l == 1: + clientId = parameters[0] + self._setClientData(clientId, "hasOptionalGUI", True) + logger.info(f"Client {clientId} supports optional GUI") + else: + raise NotImplementedError(parameters) + + + def _reactCallback_statusChanged(self, parameters:list): + """ + Handles all status messages. + Some changes, like removed and quit, are only available as status. + This means that status removed is the opposite of /nsm/gui/client/new, even if it doesn't + read like it. + /nsm/gui/client/status ['nFDBK', 'open'] + /nsm/gui/client/status ['nMAJH', 'launch'] + /nsm/gui/client/status ['nLUPX', 'ready'] + /nsm/gui/client/status ['nLUPX', 'save'] + /nsm/gui/client/status ['nFHLB', 'quit'] + /nsm/gui/client/status ['nLUPX', 'removed'] + /nsm/gui/client/status ['nLUPX', 'stopped'] + /nsm/gui/client/status ['nLUPX', 'noop'] #For dumb clients! no nsm support! + /nsm/gui/client/status ['nLUPX', 'switch'] + /nsm/gui/client/status ['nLUPX', 'error'] + """ + l = len(parameters) + if l == 2: + clientId, status = parameters + logger.info(f"Client status {clientId} now {status}") + r = self._setClientData(clientId, "lastStatus", status) + if r: #a known race condition at quit may delete this in between calls + self.internalState["clients"][clientId]["statusHistory"].append(status) + if status == "ready": #we need to check for this now. Below in actions is after the statusHook and too late. + self._setClientData(clientId, "dumbClient", False) + self.clientStatusHook(self.internalState["clients"][clientId]) + + else: + raise NotImplementedError(parameters) + + #Now handle our actions. For better readability in separate functions. + + actions = { + "open": self._reactStatus_open, + "launch": self._reactStatus_launch, + "ready": self._reactStatus_ready, + "save": self._reactStatus_save, + "quit": self._reactStatus_quit, + "removed": self._reactStatus_removed, + "stopped": self._reactStatus_stopped, + "noop": self._reactStatus_noop, + "switch": self._reactStatus_switch, + "error": self._reactStatus_error, + }[status](clientId) + actions #pylint does not like temporary dicts for case-switch + + def _reactStatus_removed(self, clientId:str): + """Remove the client entry from our internal state. + This also covers crashes.""" + if clientId in self.internalState["clients"]: #race condition at quit + del self.internalState["clients"][clientId] + + if self.dataStorage and clientId == self.dataStorage.ourClientId: #We only care about the current data-storage, not another instance that was started before it. + self.dataClientNamesHook(None) + self.dataClientDescriptionHook(None) + self.dataStorage = None + + + def _reactStatus_stopped(self, clientId:str): + """The client has stopped and can be restarted. + The status is not saved. NSM will try to open all clients on session open and end in "ready" + """ + if self.dataStorage and clientId == self.dataStorage.ourClientId: #We only care about the current data-storage, not another instance that was started before it. + self.dataClientNamesHook(None) + self.dataClientDescriptionHook(None) + self.dataStorage = None + + def _reactStatus_launch(self, clientId:str): + """ + Launch is a transitional status for NSM clients but the terminal status for dumb clients + """ + pass + + def _reactStatus_open(self, clientId:str): + """ + """ + pass + + def _reactStatus_ready(self, clientId:str): + """ + This is sent after startup but also after every save. + It signals that the client can react to nsm signals, not that it is ready for something else. + + Note that this is *After* the clientStatusHook, so any data changed here is not submitted to the + api/GUI yet. E.g. you can't change dumbClient to True here if that is needed directly + after start by the GUI. + """ + pass + + + def _reactStatus_save(self, clientId:str): + """ + """ + pass + + def _reactStatus_quit(self, clientId:str): + """ + """ + pass + + def _reactStatus_noop(self, clientId:str): + """ + Dumb clients, or rather nsmd, react with noop on signals they cannot understand, like + saving. + """ + pass + + def _reactStatus_switch(self, clientId:str): + """ + """ + pass + + def _reactStatus_error(self, clientId:str): + """ + """ + logger.error(f"{clientId} has error status!") + + def _reactReply_nsmServerOpen(self, answer:str): + assert answer == "Loaded.", answer + def _reactReply_nsmServerSave(self, answer:str): + assert answer == "Saved.", answer + def _reactReply_nsmServerClose(self, answer:str): + assert answer == "Closed.", answer + def _reactReply_nsmServerAbort(self, answer:str): + assert answer == "Aborted.", answer + def _reactReply_nsmServerAdd(self, answer:str): + """Reaction to add client""" + assert answer == "Launched.", answer + def _reactReply_nsmServerRemoved(self): + pass + def _reactReply_nsmServerStopped(self): + pass + def _reactReply_nsmServerDuplicate(self, answer:str): + """There are a lot of errors possible here, reported through nsmd /error, + because we are dealing with the file system. Our own GUI and other safeguards should + protect us from most though + + Positive answers are 'Duplicated.' when nsmd finished copying and 'Loaded.' when the new + session is loaded. Or so one would think... the messages arrive the other way around. + Anyway, both are needed to signify a succesful duplication. + """ + assert answer == "Loaded." or answer == "Duplicated.", answer + #We don't need any callbacks here, nsmd sends a session change on top of the duplicate replies. + + + def _reactReply_nsmServerNew(self, answer:str): + """Created. arrives when a new session is created for the first time and directory is mkdir + Session created arrives when a session was opened and nsm created its internal "session". + + We do not need to react to the new signal because we watch the dir for new sessions ourselves + and the currently active session is send through + "/nsm/gui/session/name" : self._reactCallback_activeSessionChanged, + """ + assert answer == 'Created.' or answer == "Session created", answer + + def _reactReply_nsmServerList(self, nsmSessionName:str): + """Session names come one reply at a time. + We reacted to the message /nsm/gui/server/message ['Listing sessions'] + by clearing our internal session status and will save the new ones here + + /reply ['/nsm/server/list', 'test3'] + + Do not confuse reply server list with the message /nsm/server/list [0, 'Done.'] + The latter is a top level message :( + """ + self.internalState["sessions"].add(nsmSessionName) + + + #Our own functions + def allClientsHide(self): + for clientId, clientDict in self.internalState["clients"].items(): + if clientDict["hasOptionalGUI"]: + self.clientHide(clientId) + + def allClientsShow(self): + for clientId, clientDict in self.internalState["clients"].items(): + if clientDict["hasOptionalGUI"]: + self.clientShow(clientId) + + def clientToggleVisible(self, clientId:str): + if self.internalState["clients"][clientId]["hasOptionalGUI"]: + if self.internalState["clients"][clientId]["visible"]: + self.clientHide(clientId) + else: + self.clientShow(clientId) + + #data-storage / nsm-data + def clientNameOverride(self, clientId:str, name:str): + """An argodejo-specific function that requires the client nsm-data in the session. + If nsm-data is not present this function will write nothing, not touch any data. + It will still send a callback to revert any GUI changes back to the original name. + + We accept empty string as a name to remove the name override + """ + if self.dataStorage: + assert clientId in self.internalState["clients"], self.internalState["clients"] + self.dataStorage.setClientOverrideName(clientId, name) #triggers callback + + #data-storage / nsm-data + def setDescription(self, text:str): + if self.dataStorage: + self.dataStorage.setDescription(text) + + def _checkDirectoryForSymlinks(self, path)->bool: + for p in path.rglob("*"): + if p.is_symlink(): + return True + return False + + def _checkIfLocked(self, nsmSessionName:str)->bool: + basePath = pathlib.Path(self.sessionRoot, nsmSessionName) + assert basePath.exists() + lockFile = pathlib.Path(basePath, ".lock") + return lockFile.exists() + + def forceLiftLock(self, nsmSessionName:str): + """Removes lockfile, no matter if session is actually open or just a remainder + from a crash. + If no lock exist it does nothing.""" + self._updateSessionListBlocking() + + if self._checkIfLocked(nsmSessionName): + basePath = pathlib.Path(self.sessionRoot, nsmSessionName) + assert basePath.exists() #implied by _checkIfLocked + lockFile = pathlib.Path(basePath, ".lock") + lockFile.unlink(missing_ok=True) + logger.info(f"{nsmSessionName} was forced to unlock by us.") + else: + logger.info(f"Tried to unlock, but {nsmSessionName} is not locked") + + def getSessionFiles(self, nsmSessionName:str)->list: + """Return all session files, useful to present to the user, e.g. as warning + before deletion""" + self._updateSessionListBlocking() + + basePath = pathlib.Path(self.sessionRoot, nsmSessionName) + assert basePath.exists() + return [f.as_posix() for f in basePath.rglob("*")] #Includes directories themselves + + #Only files, no directories themselves. + #result = [] + #for path, dirs, files in walk(basePath): + # for file in files: + # result.append(pathlib.Path(path, file).as_posix()) + #return result + + def deleteSession(self, nsmSessionName:str): + """Delete project directory with all data. No undo. + Only if session is not locked""" + self._updateSessionListBlocking() + + if not nsmSessionName in self.internalState["sessions"]: + logger.warning(f"{nsmSessionName} is not a session") + return False + + basePath = pathlib.Path(self.sessionRoot, nsmSessionName) + assert basePath.exists() + if not self._checkIfLocked(nsmSessionName): + logger.info(f"Deleting session {nsmSessionName}: {self.getSessionFiles(nsmSessionName)}") + shutilrmtree(basePath) + else: + logger.warning(f"Tried to delete {basePath} but it is locked") + + def renameSession(self, nsmSessionName:str, newName:str): + """Only works if session is not locked and dir does not exist yet""" + self._updateSessionListBlocking() + + newPath = pathlib.Path(self.sessionRoot, newName) + oldPath = pathlib.Path(self.sessionRoot, nsmSessionName) + assert oldPath.exists() + + if self._checkIfLocked(nsmSessionName): + logger.warning(f"Can't rename {nsmSessionName} to {newName}. {nsmSessionName} is locked.") + return False + elif newPath.exists(): + logger.warning(f"Can't rename {nsmSessionName} to {newName}. {newName} already exists.") + return False + else: + logger.info(f"Renaming {nsmSessionName} to {newName}.") + oldPath.rename(newPath) + assert newPath.exists() + + def copySession(self, nsmSessionName:str, newName:str): + """Copy a whole tree. Keep symlinks as symlinks. + Lift lock""" + self._updateSessionListBlocking() + + source = pathlib.Path(self.sessionRoot, nsmSessionName) + destination = pathlib.Path(self.sessionRoot, newName) + + if destination.exists(): + logger.warning(f"Can't copy {nsmSessionName} to {newName}. {newName} already exists.") + return False + elif not nsmSessionName in self.internalState["sessions"]: + logger.warning(f"{nsmSessionName} is not a session") + return + elif not source.exists(): + logger.warning(f"Can't copy {nsmSessionName} because it does not exist.") + return False + + #All is well. + try: + shutilcopytree(source, destination, symlinks=True, dirs_exist_ok=False) #raises an error if dir already exists. But we already test above. + self.forceLiftLock(newName) + except Exception as e: #we don't want to crash if user tries to copy to /root or so. + logger.error(e) + return False + + + #Export to the User Interface + def sessionAsDict(self, nsmSessionName:str)->dict: + assert self.sessionRoot + entry = {} + entry["nsmSessionName"] = nsmSessionName + entry["name"] = pathlib.Path(nsmSessionName).name + basePath = pathlib.Path(self.sessionRoot, nsmSessionName) + sessionFile = pathlib.Path(basePath, "session.nsm") + + if not sessionFile.exists(): + logger.info("Got wrong session directory from nsmd. Race condition after delete? Project: " + repr(sessionFile)) + return None + + timestamp = datetime.fromtimestamp(sessionFile.stat().st_mtime).isoformat(sep=" ", timespec='minutes') + entry["lastSavedDate"] = timestamp + entry["sessionFile"] = sessionFile + entry["lockFile"] = pathlib.Path(basePath, ".lock") + entry["fullPath"] = str(basePath) + entry["sizeInBytes"] = sum(f.stat().st_size for f in basePath.glob('**/*') if f.is_file() ) + entry["numberOfClients"] = len(open(sessionFile).readlines()) + entry["hasSymlinks"] = self._checkDirectoryForSymlinks(basePath) + entry["parents"] = basePath.relative_to(self.sessionRoot).parts[:-1] #tuple of each dir between NSM root and nsmSessionName/session.nsm, exluding the actual project name. This is the tree + entry["locked"] = self._checkIfLocked(nsmSessionName) #not for direct display + return entry + + def exportSessionsAsDicts(self)->list: + """Return a list of dicts of projects with additional information: + """ + results = [] + self._updateSessionListBlocking() + for nsmSessionName in self.internalState["sessions"]: + result = self.sessionAsDict(nsmSessionName) + results.append(result) + return results + +class DataStorage(object): + """Interface to handle the external datastorage client + url is pre-processed (host, port) + + Our init is the same as announcing the nsm-data client in the session. + That means everytime nsm-data sends a new/open reply we get created. + Thus we will send all our data to parent and subsequently to GUI-callbacks in init. + + Keys are strings, + While nsmd OSC support int, str and float we use json exclusively. + We send json string and parse the received data. + + Try to use only ints, floats, strin + gs, lists and dicts. + + Client pretty names are limited to 512 chars, depending on our OSC message size. + nsm-data will just cut to 512 chars. So a GUI should better protect that limit. + """ + + def __init__(self, parent, ourClientId, messageSizeLimit:int, url:tuple, sock): + logger.info("Create new DataStorage instance") + self.parent = parent + self.messageSizeLimit = messageSizeLimit # e.g. 512 + self.ourClientId = ourClientId + self.clients = parent.internalState["clients"] #shortcut. Mutable, persistent dict, until instance gets deleted. + self.url = url + self.sock = sock + self.ip, self.port = self.sock.getsockname() + self.data = self.getAll() #blocks. our local copy. = {"clientOverrideNames":{clientId:nameOverride}, "description":"str"} + self.namesToParentAndCallbacks() + self.descriptionToParentAndCallbacks() + + def namesToParentAndCallbacks(self): + self.parent.dataClientNamesHook(self.data["clientOverrideNames"]) + + def descriptionToParentAndCallbacks(self): + """Every char!!!""" + self.parent.dataClientDescriptionHook(self.data["description"]) + + def _waitForMultipartMessage(self, pOscpath:str)->str: + """Returns a json string, as if the message was sent as a single one. + Can consist of only one part as well.""" + logger.info(f"Waiting for multi message {pOscpath} in blocking mode") + self.parent._setPause(True) + jsonString = "" + chunkNumberOfParts = float("+inf") #zero based + currentPartNumber = float("-inf") #zero based + while True: + if currentPartNumber >= chunkNumberOfParts: + break + try: + data, addr = self.sock.recvfrom(1024) + except socket.timeout: + break + + msg = _IncomingMessage(data) + if msg.oscpath == pOscpath: + currentPartNumber, l, jsonChunk = msg.params + jsonString += jsonChunk + chunkNumberOfParts = l #overwrite infinity the first time and redundant afterwards. + else: + self.parent._queue.append(msg) + self.parent._setPause(False) + logger.info(f"Message complete with {chunkNumberOfParts} chunks.") + return jsonString + + def getAll(self): + """Mirror everything from nsm-data""" + msg = _OutgoingMessage("/argodejo/datastorage/getall") + msg.add_arg(self.ip) + msg.add_arg(self.port) + self.sock.sendto(msg.build(), self.url) + jsonString = self._waitForMultipartMessage("/argodejo/datastorage/reply/getall") + return json.loads(jsonString) + + def setClientOverrideName(self, clientId:str, value): + """We accept empty string as a name to remove the name override""" + assert clientId in self.clients, self.clients + msg = _OutgoingMessage("/argodejo/datastorage/setclientoverridename") + msg.add_arg(clientId) + msg.add_arg(json.dumps(value)) + self.sock.sendto(msg.build(), self.url) + self.getClientOverrideName(clientId) #verifies data and triggers callback + + def getClientOverrideName(self, clientId:str): + msg = _OutgoingMessage("/argodejo/datastorage/getclientoverridename") + msg.add_arg(clientId) + msg.add_arg(self.ip) + msg.add_arg(self.port) + self.sock.sendto(msg.build(), self.url) + + #Wait in blocking mode + self.parent._setPause(True) + while True: + try: + data, addr = self.sock.recvfrom(1024) + except socket.timeout: + break + + msg = _IncomingMessage(data) + if msg.oscpath == "/argodejo/datastorage/reply/getclient": + replyClientId, jsonName = msg.params + assert replyClientId == clientId, (replyClientId, clientId) + break + else: + self.parent._queue.append(msg) + self.parent._setPause(False) + #Got answer + answer = json.loads(jsonName) + if answer: + self.data["clientOverrideNames"][clientId] = answer + else: + #It is possible that a client not present in our storage will send an empty string. Protect. + if clientId in self.data["clientOverrideNames"]: + del self.data["clientOverrideNames"][clientId] + self.namesToParentAndCallbacks() + + def _chunkstring(self, string): + return [string[0+i:self.messageSizeLimit+i] for i in range(0, len(string), self.messageSizeLimit)] + + def setDescription(self, text:str): + """This most likely arrives one char at time with the complete text""" + chunks = self._chunkstring(text) + descriptionId = str(id(text))[:8] + for index, chunk in enumerate(chunks): + msg = _OutgoingMessage("/argodejo/datastorage/setdescription") + msg.add_arg(descriptionId) + msg.add_arg(index) + msg.add_arg(chunk) + msg.add_arg(self.ip) + msg.add_arg(self.port) + self.sock.sendto(msg.build(), self.url) + + #No echo answer. + #We cheat a bit and inform parents with the new text directly. + self.data["description"] = text + self.descriptionToParentAndCallbacks() #and back + + #Generic Functions. Not in use and not ready. + def _test(self): + self.readAll() + self.setDescription("Ein Jäger aus Kurpfalz,\nDer reitet durch den grünen Wald,\nEr schießt das Wild daher,\nGleich wie es ihm gefällt.") + self.read("welt") + self.create("welt", "world") + self.read("welt") + self.create("str", "bar") + self.create("int", 1) + self.create("list", [1, 2, 3]) + self.create("tuple", (1, 2, 3)) #no tuples, everything will be a list. + self.create("dict", {1:2, 3:4, 5:6}) + self.update("str", "rolf") + self.delete("str") + + def read(self, key:str): + """Request one value""" + msg = _OutgoingMessage("/argodejo/datastorage/read") + msg.add_arg(key) + msg.add_arg(self.ip) + msg.add_arg(self.port) + self.sock.sendto(msg.build(), self.url) + + def readAll(self): + """Request all data""" + msg = _OutgoingMessage("/argodejo/datastorage/readall") + msg.add_arg(self.ip) + msg.add_arg(self.port) + self.sock.sendto(msg.build(), self.url) + + def create(self, key:str, value): + """Write/Create one value.""" + msg = _OutgoingMessage("/argodejo/datastorage/create") + msg.add_arg(key) + msg.add_arg(json.dumps(value)) + self.sock.sendto(msg.build(), self.url) + + def update(self, key:str, value): + """Update a value, but only if it exists""" + msg = _OutgoingMessage("/argodejo/datastorage/update") + msg.add_arg(key) + msg.add_arg(json.dumps(value)) + self.sock.sendto(msg.build(), self.url) + + def delete(self, key:str): + """Delete a key/value completely""" + msg = _OutgoingMessage("/argodejo/datastorage/delete") + msg.add_arg(key) + self.sock.sendto(msg.build(), self.url) diff --git a/engine/old b/engine/old new file mode 100644 index 0000000..66f20ef --- /dev/null +++ b/engine/old @@ -0,0 +1,33 @@ + def projectAsDict(self, nsmName:str)->dict: + entry = {} + entry["nsmName"] = nsmName + entry["name"] = os.path.basename(nsmName) + basePath = pathlib.Path(PATHS["sessionRoot"], nsmName) + sessionFile = pathlib.Path(basePath, "session.nsm") + + if not sessionFile.exists(): + logging.info("Got wrong session directory from nsmd. Race condition after delete? Project: " + repr(sessionFile)) + return None + + timestamp = datetime.fromtimestamp(sessionFile.stat().st_mtime).isoformat(sep=" ", timespec='minutes') + entry["lastSavedDate"] = timestamp + entry["sessionFile"] = sessionFile + entry["lockFile"] = pathlib.Path(basePath, ".lock") + entry["fullPath"] = str(basePath) + entry["sizeInBytes"] = sum(f.stat().st_size for f in basePath.glob('**/*') if f.is_file() ) + entry["numberOfClients"] = len(open(sessionFile).readlines()) + entry["hasSymlinks"] = self._checkDirectoryForSymlinks(basePath) + entry["parents"] = basePath.relative_to(PATHS["sessionRoot"]).parts[:-1] #tuple of each dir between NSM root and nsmName/session.nsm, exluding the actual project name. This is the tree + entry["locked"] = self._checkIfLocked(nsmName) #not for direct display + return entry + + def exportProjects(self)->list: + """Return a list of dicts of projects with additional information: + """ + results = [] + data = self._requestProjects() #False for auto-sent data by nsmd + for nsmName in data: + result = self.projectAsDict(nsmName) + results.append(result) + return results + diff --git a/engine/start.py b/engine/start.py new file mode 100644 index 0000000..d4c21b2 --- /dev/null +++ b/engine/start.py @@ -0,0 +1,220 @@ +#! /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 ), +more specifically its template base application. + +The Template Base 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 . +""" + +#This is the first file in the program to be actually executed, after the executable which uses this as first instruction. + +""" +We use a 'wrong' scheme of importing modules here because there are multiple exit conditions, good and bad. +We don't want to use all the libraries, including the big Qt one, only to end up displaying the --version and exit. +Same with the tests if jack or nsm are running. +""" + +from engine.config import * #includes METADATA only. No other environmental setup is executed. +from qtgui.helper import setPaletteAndFont #our error boxes shall look like the rest of the program + +""" +Check parameters first. It is possible that we will just --help or --version and exit. In this case +nothing gets loaded. +""" +import os.path +import argparse +parser = argparse.ArgumentParser(description=f"""{METADATA["name"]} - Version {METADATA["version"]} - Copyright {METADATA["year"]} by {METADATA["author"]} - {METADATA["url"]}""") +parser.add_argument("-v", "--version", action='version', version="{} {}".format(METADATA["name"], METADATA["version"])) +parser.add_argument("-V", "--verbose", action='store_true', help="(Development) Switch the logger to INFO and give out all kinds of information to get a high-level idea of what the program is doing.") + +parser.add_argument("-u", "--url", action='store', dest="url", help="Force URL for the session. If there is already a running session we will connect to it. Otherwise we will start one there. Default is local host with random port. Example: osc.udp://myhost.localdomain:14294/") +parser.add_argument("--nsm-url", action='store', dest="url", help="Same as --url.") +parser.add_argument("-s", "--session", action='store', dest="session", help="Session to open on startup.") +parser.add_argument("--session-root", action='store', dest="sessionRoot", help="Root directory of all sessions. Defaults to '$HOME/NSM Sessions'") + +args = parser.parse_args() + + +import logging +if args.verbose: + logging.basicConfig(level=logging.INFO) #development + #logging.getLogger().setLevel(logging.INFO) #development +else: + logging.basicConfig(level=logging.ERROR) #production + #logging.getLogger().setLevel(logging.ERROR) #production + +logger = logging.getLogger(__name__) +logger.info("import") + + +"""set up python search path before the program starts and cbox gets imported. +We need to be earliest, so let's put it here. +This is influence during compiling by creating a temporary file "compiledprefix.py". +Nuitka complies that in, when make is finished we delete it. + +#Default mode is a self-contained directory relative to the uncompiled patroneo python start script +""" + +import sys +import os +import os.path +try: + from compiledprefix import prefix + compiledVersion = True + logger.info("Compiled prefix found: {}".format(prefix)) +except ModuleNotFoundError as e: + compiledVersion = False + +logger.info("Compiled version: {}".format(compiledVersion)) + +cboxSharedObjectVersionedName = "lib"+METADATA["shortName"]+".so." + METADATA["version"] + +if compiledVersion: + PATHS={ #this gets imported + "root": "", + "bin": os.path.join(prefix, "bin"), + "doc": os.path.join(prefix, "share", "doc", METADATA["shortName"]), + "desktopfile": os.path.join(prefix, "share", "applications", METADATA["shortName"] + ".desktop"), #not ~/Desktop but our desktop file + "share": os.path.join(prefix, "share", METADATA["shortName"]), + "templateShare": os.path.join(prefix, "share", METADATA["shortName"], "template"), + "sessionRoot": args.sessionRoot, + "url": args.url, + "startupSession": args.session, + #"lib": os.path.join(prefix, "lib", METADATA["shortName"]), #cbox is found via the PYTHONPATH + } + + cboxSharedObjectPath = os.path.join(prefix, "lib", METADATA["shortName"], cboxSharedObjectVersionedName) + _root = os.path.dirname(__file__) + _root = os.path.abspath(os.path.join(_root, "..")) + fallback_cboxSharedObjectPath = os.path.join(_root, "site-packages", cboxSharedObjectVersionedName) + + #Local version has higher priority + + + if os.path.exists(fallback_cboxSharedObjectPath): #we are not yet installed, look in the source site-packages dir + os.environ["CALFBOXLIBABSPATH"] = fallback_cboxSharedObjectPath + elif os.path.exists(cboxSharedObjectPath): #we are installed + os.environ["CALFBOXLIBABSPATH"] = cboxSharedObjectPath + + else: + pass + #no support for system-wide cbox in compiled mode. Error handling at the bottom of the file + + +else: + _root = os.path.dirname(__file__) + _root = os.path.abspath(os.path.join(_root, "..")) + PATHS={ #this gets imported + "root": _root, + "bin": _root, + "doc": os.path.join(_root, "documentation", "out"), + "desktopfile": os.path.join(_root, "desktop", "desktop.desktop"), #not ~/Desktop but our desktop file + "share": os.path.join(_root, "engine", "resources"), + "templateShare": os.path.join(_root, "template", "engine", "resources"), + "sessionRoot": args.sessionRoot, + "url": args.url, + "startupSession": args.session, + #"lib": "", #use only system paths + } + if os.path.exists (os.path.join(_root, "site-packages", cboxSharedObjectVersionedName)): + os.environ["CALFBOXLIBABSPATH"] = os.path.join(_root, "site-packages", cboxSharedObjectVersionedName) + #else use system-wide. + + if os.path.exists (os.path.join(_root, "site-packages", "calfbox", "cbox.py")): + #add to the front to have higher priority than system site-packages + logger.info("Will attempt to start with local calfbox python module: {}".format(os.path.join(_root, "site-packages", "calfbox", "cbox.py"))) + sys.path.insert(0, os.path.join(os.path.join(_root, "site-packages"))) + #else try to use system-wide calfbox. Check for this and if the .so exists at the end of this file. + + +logger.info("PATHS: {}".format(PATHS)) + + +def exitWithMessage(message:str): + title = f"""{METADATA["name"]} Error""" + if sys.stdout.isatty(): + sys.exit(title + ": " + message) + else: + from PyQt5.QtWidgets import QMessageBox, QApplication + #This is the start file for the Qt client so we know at least that Qt is installed and use that for a warning. + qErrorApp = QApplication(sys.argv) + setPaletteAndFont(qErrorApp) + QMessageBox.critical(qErrorApp.desktop(), title, message) + qErrorApp.quit() + sys.exit(title + ": " + message) + +def setProcessName(executableName): + """From + https://stackoverflow.com/questions/31132342/change-process-name-while-executing-a-python-script + """ + import ctypes + lib = ctypes.cdll.LoadLibrary(None) + prctl = lib.prctl + prctl.restype = ctypes.c_int + prctl.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_ulong, + ctypes.c_ulong, ctypes.c_ulong] + def set_proctitle(new_title): + result = prctl(15, new_title, 0, 0, 0) + if result != 0: + raise OSError("prctl result: %d" % result) + set_proctitle(executableName.encode()) + +def _is_jack_running(): + """Check for JACK""" + import ctypes + import os + silent = os.open(os.devnull, os.O_WRONLY) + stdout = os.dup(1) + stderr = os.dup(2) + os.dup2(silent, 1) #stdout + os.dup2(silent, 2) #stderr + cjack = ctypes.cdll.LoadLibrary("libjack.so.0") + class jack_client_t(ctypes.Structure): + _fields_ = [] + cjack.jack_client_open.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] #the two ints are enum and pointer to enum. #http://jackaudio.org/files/docs/html/group__ClientFunctions.html#gab8b16ee616207532d0585d04a0bd1d60 + cjack.jack_client_open.restype = ctypes.POINTER(jack_client_t) + ctypesJackClient = cjack.jack_client_open("probe".encode("ascii"), 0x01, None) #0x01 is the bit set for do not autostart JackNoStartServer + try: + ret = bool(ctypesJackClient.contents) + except ValueError: #NULL pointer access + ret = False + cjack.jack_client_close(ctypesJackClient) + os.dup2(stdout, 1) #stdout + os.dup2(stderr, 2) #stderr + return ret + +def checkJackOrExit(prettyName): + import sys + if not _is_jack_running(): + exitWithMessage("JACK Audio Connection Kit is not running. Please start it.") + + +checkJackOrExit(METADATA["name"]) +setProcessName(METADATA["shortName"]) + + +#Catch Exceptions even if PyQt crashes. +import sys +sys._excepthook = sys.excepthook +def exception_hook(exctype, value, traceback): + """This hook purely exists to call sys.exit(1) even on a Qt crash + so that atexit gets triggered""" + #print(exctype, value, traceback) + logger.error("Caught crash in execpthook. Trying too execute atexit anyway") + sys._excepthook(exctype, value, traceback) + sys.exit(1) +sys.excepthook = exception_hook diff --git a/engine/watcher.py b/engine/watcher.py new file mode 100644 index 0000000..eb283cc --- /dev/null +++ b/engine/watcher.py @@ -0,0 +1,154 @@ +#! /usr/bi n/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), +more specifically its template base application. + +The Template Base 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 +from datetime import datetime +import pathlib +import os +#Our Modules +from engine.start import PATHS + +def fast_scandir(dir): + """ + Get all subdirectories recursively. + https://stackoverflow.com/questions/973473/getting-a-list-of-all-subdirectories-in-the-current-directory""" + subfolders= [f.path for f in os.scandir(dir) if f.is_dir()] + for dir in list(subfolders): + subfolders.extend(fast_scandir(dir)) + return subfolders + + +class Watcher(object): + """ + Initialize with the server controller + + The watcher will should only run when the program is in the mode to choose a session. + If a session is loaded it will technically work, but will be a waste of resources. + + The watcher will also trigger the nsmController to redundantly query for a session list, + for example when a new empty sessions gets created or duplicated. Once because nsmd sends the + "new" signal, and then our watcher will notice the change itself. However, this happens only so + often and can be accepted, so the program does not need more checks and exceptions. + + Inform you via callback when: + + a) a session dir was deleted + b) a new directory got created. + c) session directory renamed or moved + d) session.nsm changed timestamp + e) A lockfile appeared or disappeared + + We cannot poll nsmServerControl.exportSessionsAsDicts() because that triggers an nsmd response + including log message and will flood their console. Instead this class only offers incremental + updates. + Another way is to watch the dir for changes ourselves in in some cases request a new project + list from nsmd. + + Lockfile functionality goes beyond what NSM offers. A session-daemon can open only one + project at a time. If you try to open another project with a second GUI (but same NSM-URL) + it goes wrong a bit, at least in the non-session-manager GUI. They will leave a zombie lockfile. + However, it still is possible to open a second nsmd instance. In this case the lockfile prevents + opening a session twice. And we are reflecting the opened state of the project, no matter from + which daemon. Horray for us :) + + Clients can only be added or removed while a session is locked. We do not check nor update + number of clients or symlinks. Therefore we advice a GUI to deactivate the display of + these two values while a session is locked (together with size) + """ + + def __init__(self, nsmServerControl): + self.active = True + + self._nsmServerControl = nsmServerControl + + assert self._nsmServerControl.sessionRoot + + self._directories = fast_scandir(self._nsmServerControl.sessionRoot) + + logger.info("Requestion our own copy of the session list. Don't worry about the apparent redundant call :)") + self._lastExport = self._nsmServerControl.exportSessionsAsDicts() + self._lastTimestamp = {d["nsmSessionName"]:d["lastSavedDate"] for d in self._lastExport} #str:str + #Init all values with None will send the initial state via callback on program start, which is what the user wants to know. + self._lastLockfile = {d["nsmSessionName"]:None for d in self._lastExport} #str:bool + + self.timeStampHook = None # a single function that gets informed of changes, most likely the api callback + self.lockFileHook = None # a single function that gets informed of changes, most likely the api callback + self.sessionsChangedHook = None # the api callback function api.callbacks._sessionsChanged. Rarely used. + + def resume(self, *args): + """For api callbacks""" + self.active = True + #If we returned from an open session that will surely have changed. Trigger a single poll + self.process() + logger.info("Watcher resumed") + + def suspend(self, *args): + """For api callbacks""" + self.active = False + logger.info("Watcher suspended") + + def process(self): + """Add this to your event loop. + + We look for any changes in the directory structure. + If we detect any we simply trigger a new NSM export and a new NSM generated project + list via callback. We do not expect this to happen often. + + This will also trigger if we add a new session ourselves. This *is* our way to react to + new Sessions. + """ + if not self.active: + return + + if self.sessionsChangedHook: + current_directories = fast_scandir(self._nsmServerControl.sessionRoot) + + if not self._directories == current_directories: + self._directories = current_directories + self._lastExport = self.sessionsChangedHook() #will gather its own data, send it to api callbacks, but also return for us. + self._lastTimestamp = {d["nsmSessionName"]:d["lastSavedDate"] for d in self._lastExport} #str:str + self._lastLockfile = {d["nsmSessionName"]:None for d in self._lastExport} #str:bool + + #Now check the incremental hooks. + #No hooks, no reason to process + if not (self.timeStampHook or self.lockFileHook): + logger.info("No watcher-hooks to process") + return + + for entry in self._lastExport: + nsmSessionName = entry["nsmSessionName"] + + #Timestamp of session.nsm + if self.timeStampHook: + timestamp = datetime.fromtimestamp(entry["sessionFile"].stat().st_mtime).isoformat(sep=" ", timespec='minutes') #same format as server control export + if not timestamp == self._lastTimestamp[nsmSessionName]: #This will only trigger on a minute-based slot, which is all we want and need. This is for relaying information to the user, not for advanced processing. + self._lastTimestamp[nsmSessionName] = timestamp + self.timeStampHook(nsmSessionName, timestamp) + + #Lockfiles + if self.lockFileHook: + lockfileState = entry["lockFile"].is_file() + if not self._lastLockfile[nsmSessionName] == lockfileState: + self._lastLockfile[nsmSessionName] = lockfileState + self.lockFileHook(nsmSessionName, lockfileState) diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..01d2cd485d49c0c8117c1c2580fa29f931cb9e84 GIT binary patch literal 1772 zcmVWNu;3RBoIH6Ft*x!U zxjp-%xw$#a&(G(!O#tTb@UU4f8Xg|bb%k<0^>ZqF05(xZ-@kvyix)3M+3l+C?rt1D zd{~H=5CEoWVtRTSeSLi*>`LnKd%V%3}77q2L}hy)YOEAh6Y@}etlo-&KDN| zMNyaxzYM*d8%$*6i0HjhWrUCH5g9kWx@L+D+?RH~gVuIR7 zL?++@z>)#Fy1H=g+_`Q0&!0b!o}L~mFA>)STmaJPG&VLih~pg&2d1W`iXNYwoP^Wq zB=-CK`7_e#G;#b+;sUU`x(d@YiQ|WchS1p9SoHX@W5;mg#to`A#9}e3_(E|3h(sd9 zu^x|y=*tZa4WYiip4gW+6EFi%6ou*{=;6bML^C&+%Y{de9ufP7!(phZN*rG(W&ku9 z;OyD6@Or((-ltEW#+fr`h+<_~hOX>Krinx% zLF`>9W&ku9V0n3&svAs9OyI+Z4|~# zU;?1)I`tHIYikRGgM%gG1_lQ3+4Jv`h30}Pgh;naqZeQCeLItNi`+U7ZZSZJkDI<%E}7v-@m_Y z|M>VgK7IPcm;mt3zsJVL5Rb=m+sR}SMQCTalt`798vBuUy^03tKMUPzW@qE)1PQ30@6EL@8Z`Kqt4 z$IQ$OUcY{grluw#p7D5``q@D$0BdV&+!?@bx8vr`n~)?4Jv}|>=;%N+8pXYP_n5Ef zvN8;VIz>|f;C&df*q1GY!lT8-MXGpW0J^R-KbUmn$Pokrfs&_twp#>)K^#4Ll&P-w z@844mgv0<;Riz#PD=I4B^ZD@c<40V)c#*iA*XxBONf;R!q3#y4Ebk2fyWLI&puN2v zkw^r7zaN#AmBi(;a=Bc%bLS40mX^@n-A!DMHWLs7u-RzQBfI{> znM{T%6A%MfT3RZ4?9{1K2#3S)`~9e@sv@qOR&KXD*Dbobx{Ai_*49E10VtLMs;jFJ z2n6u%-8(clHxpONtA>UKynOi*!C(-zwYA%hFDxt&FOw6k2dJuwy1F`CzI-{iud%Ta z7cN{tRaF%fMS;`lr0RRTQdJdgZEcvJpU2auPqDnboIB?6c%Z5(>~?!eA0hy5w;R2^ zz3A=j-SN0-nz^U7vQyDw|BLO*`sA-R=WiG82LP+pibIDEp})U>TboET0TBS3%?6uI z*!xv~l>UEhF8krln>WJV!uZoQH#e92T=p+dTLJt9;BWkbQBE=e{PQp9F%AUR%a0NO O0000. +""" + +import logging; logger = logging.getLogger(__name__); logger.info("import") + +#Standard Library + +#Engine +import engine.api as api + +#Third Party +from PyQt5 import QtCore, QtWidgets + +class PromptWidget(QtWidgets.QDialog): + + wordlist = None + minlen = None + + def __init__(self, parent): + super().__init__(parent) + layout = QtWidgets.QFormLayout() + #layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + self.setWindowFlag(QtCore.Qt.Popup, True) + + self.comboBox = QtWidgets.QComboBox(self) + self.comboBox.setEditable(True) + + if PromptWidget.wordlist: + completer = QtWidgets.QCompleter(PromptWidget.wordlist) + completer.setModelSorting(QtWidgets.QCompleter.CaseInsensitivelySortedModel) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion) + completer.activated.connect(self.process) #To avoid double-press enter to select one we hook into the activated signal and trigger process + self.comboBox.setCompleter(completer) + self.comboBox.setMinimumContentsLength(PromptWidget.minlen) + labelString = QtCore.QCoreApplication.translate("PromptWidget", "Type in the name of an executable file on your system.") + else: + labelString = QtCore.QCoreApplication.translate("PromptWidget", "No program database found. Please update through Control menu.") + + label = QtWidgets.QLabel(labelString) + layout.addWidget(label) + self.comboBox.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToMinimumContentsLength) + + layout.addWidget(self.comboBox) + + errorString = QtCore.QCoreApplication.translate("PromptWidget", "Command not accepted!
Parameters, --switches and relative paths are not allowed.
Use nsm-proxy or write a starter-script instead.") + errorString = "" + errorString + "" + self.errorLabel = QtWidgets.QLabel(errorString) + layout.addWidget(self.errorLabel) + self.errorLabel.hide() #shown in process + + buttonBox = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) + buttonBox.accepted.connect(self.process) + buttonBox.rejected.connect(self.reject) + layout.addWidget(buttonBox) + + self.exec() #blocks until the dialog gets closed + + + + def abortHandler(self): + pass + + def process(self): + """Careful! Calling this eats python errors without notice. + Make sure your objects exists and your syntax is correct""" + assert PromptWidget.wordlist + assert PromptWidget.minlen + + curText = self.comboBox.currentText() + if curText in PromptWidget.wordlist: + api.clientAdd(curText) + logger.info(f"Prompt accepted {curText} and will send it to clientAdd") + self.done(True) + else: + logger.info(f"Prompt did not accept {curText}.Showing info to the user.") + self.errorLabel.show() + + +def updateWordlist(): + """in case programs are installed while the session is running the user can + manually call a database update""" + PromptWidget.wordlist = api.getUnfilteredExecutables() + if PromptWidget.wordlist: + PromptWidget.minlen = len(max(PromptWidget.wordlist, key=len)) + else: + logger.error("Executable list came back empty! Most likely an error in application database build. Not trivial!") + +def askForExecutable(parent): + PromptWidget(parent) diff --git a/qtgui/descriptiontextwidget.py b/qtgui/descriptiontextwidget.py new file mode 100644 index 0000000..20d2558 --- /dev/null +++ b/qtgui/descriptiontextwidget.py @@ -0,0 +1,99 @@ +#! /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 ) + +The Template Base 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 + +#Engine +import engine.api as api + + +#Third Party +from PyQt5 import QtCore, QtGui, QtWidgets + + +class DescriptionController(object): + """Not a subclass. Controls a TextEditWidget to hold the session-description from nsm-data. + Can be used on multiple text widgets. One controller for one textwidget.""" + + def __init__(self, mainWindow, parentGroupBox:QtWidgets.QGroupBox, plainTextWidget:QtWidgets.QPlainTextEdit): + self.mainWindow = mainWindow + self.description = plainTextWidget + self.parentGroupBox = parentGroupBox + + self.placeHolderDescription = QtCore.QCoreApplication.translate("LoadedSessionDescription", "Double click to add the client nsm-data to write here.\nUse it for notes, TODO, references etc…") + #We do NOT use the Qt placeholder text because that shows even when editing is possible. Our visual feedback is changing from placeholder to empty. + self.description.setEnabled(False) + self._reactCallback_dataClientDescriptionChanged(None) #set up description + self.parentGroupBox.mouseDoubleClickEvent = self._doubleClickToResumeOrAddNsmData + self.description.textChanged.connect(self.sendDescriptionToApi) #TODO: this is every keystroke, but it is impossible to solve a multi-GUI network system where a save signal comes from outside.. we need update every char. Maybe a genius in the future will solve this. + #self.description.focusOutEvent = self._descriptionFocusOut + + api.callbacks.dataClientDescriptionChanged.append(self._reactCallback_dataClientDescriptionChanged) #Session description + + def sendDescriptionToApi(self): + """We cannot send every keystroke over the OSC-network. Therefore we wait: + This is called by several events that "feel" like editing is done now: + Focus out, Ctlr+S, Alt+S.""" + api.setDescription(self.description.toPlainText()) #this is not save yet. Just forward to data client. + + #def _descriptionFocusOut(self, event): + # self.sendDescriptionToApi() + # QtWidgets.QPlainTextEdit.focusOutEvent(self.description, event) + + def _reactCallback_dataClientDescriptionChanged(self, desc:str): + """Put the session description into our text field. + We send each change, so we receive this signal each detail change. + + The cursor changes in between so we force the position. + """ + self.description.blockSignals(True) + if not desc is None: #may be None for closing session + oldPos = self.description.textCursor().position() + self.description.setPlainText(desc) #plain textedit + self.description.setEnabled(True) + c = self.description.textCursor() + c.setPosition(oldPos) + self.description.setTextCursor(c) + else: + self.description.setEnabled(False) + self.description.setPlainText(self.placeHolderDescription) + self.description.blockSignals(False) + + def _doubleClickToResumeOrAddNsmData(self, event): + """Intended for doubleClickEvent, so we get an event. + Do nothing when nsm-data is present. Add it when it was never there. + Resume it if stopped in the session. + + When QPlainTextEdit is disabled it will forward doubleClick to the parent widget, + which is this function. If enabled the groupBox description and frame will be clickable, we + do nothing in this case. + """ + if self.description.isEnabled(): + pass + else: + d = api.executableInSession("nsm-data") + if d: + api.clientResume(d["clientId"]) + else: + api.clientAdd("nsm-data") + QtWidgets.QGroupBox.mouseDoubleClickEvent(self.parentGroupBox, event) diff --git a/qtgui/designer/mainwindow.py b/qtgui/designer/mainwindow.py new file mode 100644 index 0000000..a17561c --- /dev/null +++ b/qtgui/designer/mainwindow.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'mainwindow.ui' +# +# Created by: PyQt5 UI code generator 5.14.1 +# +# WARNING! All changes made in this file will be lost! + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(953, 763) + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) + self.verticalLayout.setObjectName("verticalLayout") + self.tabbyCat = QtWidgets.QTabWidget(self.centralwidget) + self.tabbyCat.setObjectName("tabbyCat") + self.tab_quick = QtWidgets.QWidget() + self.tab_quick.setObjectName("tab_quick") + self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.tab_quick) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.quickStackedWidget = QtWidgets.QStackedWidget(self.tab_quick) + self.quickStackedWidget.setObjectName("quickStackedWidget") + self.page_quickSessionChooser = QtWidgets.QWidget() + self.page_quickSessionChooser.setObjectName("page_quickSessionChooser") + self.verticalLayout_9 = QtWidgets.QVBoxLayout(self.page_quickSessionChooser) + self.verticalLayout_9.setObjectName("verticalLayout_9") + self.quickNewSession = QtWidgets.QPushButton(self.page_quickSessionChooser) + self.quickNewSession.setAutoDefault(True) + self.quickNewSession.setDefault(True) + self.quickNewSession.setObjectName("quickNewSession") + self.verticalLayout_9.addWidget(self.quickNewSession) + self.scrollArea = QtWidgets.QScrollArea(self.page_quickSessionChooser) + self.scrollArea.setWidgetResizable(True) + self.scrollArea.setObjectName("scrollArea") + self.quickSessionChooser = QtWidgets.QWidget() + self.quickSessionChooser.setGeometry(QtCore.QRect(0, 0, 98, 28)) + self.quickSessionChooser.setObjectName("quickSessionChooser") + self.verticalLayout_7 = QtWidgets.QVBoxLayout(self.quickSessionChooser) + self.verticalLayout_7.setObjectName("verticalLayout_7") + spacerItem = QtWidgets.QSpacerItem(20, 586, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_7.addItem(spacerItem) + self.scrollArea.setWidget(self.quickSessionChooser) + self.verticalLayout_9.addWidget(self.scrollArea) + self.quickStackedWidget.addWidget(self.page_quickSessionChooser) + self.page_quickSessionLoaded = QtWidgets.QWidget() + self.page_quickSessionLoaded.setObjectName("page_quickSessionLoaded") + self.verticalLayout_8 = QtWidgets.QVBoxLayout(self.page_quickSessionLoaded) + self.verticalLayout_8.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_8.setSpacing(0) + self.verticalLayout_8.setObjectName("verticalLayout_8") + self.quickSessionNameLineEdit = QtWidgets.QLineEdit(self.page_quickSessionLoaded) + self.quickSessionNameLineEdit.setAlignment(QtCore.Qt.AlignCenter) + self.quickSessionNameLineEdit.setObjectName("quickSessionNameLineEdit") + self.verticalLayout_8.addWidget(self.quickSessionNameLineEdit) + self.widget_2 = QtWidgets.QWidget(self.page_quickSessionLoaded) + self.widget_2.setObjectName("widget_2") + self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.widget_2) + self.horizontalLayout_4.setContentsMargins(0, -1, 0, -1) + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + self.quickSaveOpenSession = QtWidgets.QPushButton(self.widget_2) + self.quickSaveOpenSession.setObjectName("quickSaveOpenSession") + self.horizontalLayout_4.addWidget(self.quickSaveOpenSession) + self.quickCloseOpenSession = QtWidgets.QPushButton(self.widget_2) + self.quickCloseOpenSession.setObjectName("quickCloseOpenSession") + self.horizontalLayout_4.addWidget(self.quickCloseOpenSession) + self.verticalLayout_8.addWidget(self.widget_2) + self.splitter = QtWidgets.QSplitter(self.page_quickSessionLoaded) + self.splitter.setOrientation(QtCore.Qt.Vertical) + self.splitter.setObjectName("splitter") + self.quickSessionNotesGroupBox = QtWidgets.QGroupBox(self.splitter) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.quickSessionNotesGroupBox.sizePolicy().hasHeightForWidth()) + self.quickSessionNotesGroupBox.setSizePolicy(sizePolicy) + self.quickSessionNotesGroupBox.setObjectName("quickSessionNotesGroupBox") + self.verticalLayout_10 = QtWidgets.QVBoxLayout(self.quickSessionNotesGroupBox) + self.verticalLayout_10.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_10.setSpacing(0) + self.verticalLayout_10.setObjectName("verticalLayout_10") + self.quickSessionNotesPlainTextEdit = QtWidgets.QPlainTextEdit(self.quickSessionNotesGroupBox) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.quickSessionNotesPlainTextEdit.sizePolicy().hasHeightForWidth()) + self.quickSessionNotesPlainTextEdit.setSizePolicy(sizePolicy) + self.quickSessionNotesPlainTextEdit.setObjectName("quickSessionNotesPlainTextEdit") + self.verticalLayout_10.addWidget(self.quickSessionNotesPlainTextEdit) + self.quickSessionClientsListWidget = QtWidgets.QListWidget(self.splitter) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(6) + sizePolicy.setHeightForWidth(self.quickSessionClientsListWidget.sizePolicy().hasHeightForWidth()) + self.quickSessionClientsListWidget.setSizePolicy(sizePolicy) + self.quickSessionClientsListWidget.setViewMode(QtWidgets.QListView.IconMode) + self.quickSessionClientsListWidget.setObjectName("quickSessionClientsListWidget") + self.verticalLayout_8.addWidget(self.splitter) + self.quickStackedWidget.addWidget(self.page_quickSessionLoaded) + self.horizontalLayout_2.addWidget(self.quickStackedWidget) + self.tabbyCat.addTab(self.tab_quick, "") + self.tab_detailed = QtWidgets.QWidget() + self.tab_detailed.setObjectName("tab_detailed") + self.horizontalLayout = QtWidgets.QHBoxLayout(self.tab_detailed) + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout.setSpacing(0) + self.horizontalLayout.setObjectName("horizontalLayout") + self.detailedStackedWidget = QtWidgets.QStackedWidget(self.tab_detailed) + self.detailedStackedWidget.setObjectName("detailedStackedWidget") + self.stack_no_session = QtWidgets.QWidget() + self.stack_no_session.setObjectName("stack_no_session") + self.l_2 = QtWidgets.QHBoxLayout(self.stack_no_session) + self.l_2.setContentsMargins(9, 9, 9, 9) + self.l_2.setSpacing(6) + self.l_2.setObjectName("l_2") + self.widget = QtWidgets.QWidget(self.stack_no_session) + self.widget.setObjectName("widget") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.widget) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.button_new_session = QtWidgets.QPushButton(self.widget) + self.button_new_session.setObjectName("button_new_session") + self.verticalLayout_3.addWidget(self.button_new_session) + self.button_load_selected_session = QtWidgets.QPushButton(self.widget) + self.button_load_selected_session.setObjectName("button_load_selected_session") + self.verticalLayout_3.addWidget(self.button_load_selected_session) + self.checkBoxNested = QtWidgets.QCheckBox(self.widget) + self.checkBoxNested.setChecked(True) + self.checkBoxNested.setObjectName("checkBoxNested") + self.verticalLayout_3.addWidget(self.checkBoxNested) + spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_3.addItem(spacerItem1) + self.l_2.addWidget(self.widget) + self.session_tree = QtWidgets.QTreeWidget(self.stack_no_session) + self.session_tree.setObjectName("session_tree") + self.session_tree.headerItem().setText(0, "1") + self.l_2.addWidget(self.session_tree) + self.detailedStackedWidget.addWidget(self.stack_no_session) + self.stack_loaded_session = QtWidgets.QWidget() + self.stack_loaded_session.setObjectName("stack_loaded_session") + self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.stack_loaded_session) + self.verticalLayout_4.setContentsMargins(9, 9, 9, 9) + self.verticalLayout_4.setSpacing(0) + self.verticalLayout_4.setObjectName("verticalLayout_4") + self.vSplitterProgramsLog = QtWidgets.QSplitter(self.stack_loaded_session) + self.vSplitterProgramsLog.setOrientation(QtCore.Qt.Vertical) + self.vSplitterProgramsLog.setObjectName("vSplitterProgramsLog") + self.hSplitterLauncherClients = QtWidgets.QSplitter(self.vSplitterProgramsLog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(7) + sizePolicy.setHeightForWidth(self.hSplitterLauncherClients.sizePolicy().hasHeightForWidth()) + self.hSplitterLauncherClients.setSizePolicy(sizePolicy) + self.hSplitterLauncherClients.setOrientation(QtCore.Qt.Horizontal) + self.hSplitterLauncherClients.setChildrenCollapsible(False) + self.hSplitterLauncherClients.setObjectName("hSplitterLauncherClients") + self.session_programs = QtWidgets.QGroupBox(self.hSplitterLauncherClients) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(7) + sizePolicy.setHeightForWidth(self.session_programs.sizePolicy().hasHeightForWidth()) + self.session_programs.setSizePolicy(sizePolicy) + self.session_programs.setObjectName("session_programs") + self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.session_programs) + self.verticalLayout_5.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_5.setSpacing(0) + self.verticalLayout_5.setObjectName("verticalLayout_5") + self.loadedSessionsLauncher = QtWidgets.QTreeWidget(self.session_programs) + self.loadedSessionsLauncher.setDragDropMode(QtWidgets.QAbstractItemView.DragDrop) + self.loadedSessionsLauncher.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + self.loadedSessionsLauncher.setIconSize(QtCore.QSize(64, 64)) + self.loadedSessionsLauncher.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.loadedSessionsLauncher.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.loadedSessionsLauncher.setObjectName("loadedSessionsLauncher") + self.loadedSessionsLauncher.headerItem().setText(0, "1") + self.verticalLayout_5.addWidget(self.loadedSessionsLauncher) + self.session_loaded = QtWidgets.QGroupBox(self.hSplitterLauncherClients) + self.session_loaded.setObjectName("session_loaded") + self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.session_loaded) + self.verticalLayout_6.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_6.setSpacing(0) + self.verticalLayout_6.setObjectName("verticalLayout_6") + self.loadedSessionClients = QtWidgets.QTreeWidget(self.session_loaded) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(3) + sizePolicy.setHeightForWidth(self.loadedSessionClients.sizePolicy().hasHeightForWidth()) + self.loadedSessionClients.setSizePolicy(sizePolicy) + self.loadedSessionClients.setDragDropMode(QtWidgets.QAbstractItemView.DropOnly) + self.loadedSessionClients.setAlternatingRowColors(True) + self.loadedSessionClients.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.loadedSessionClients.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.loadedSessionClients.setObjectName("loadedSessionClients") + self.loadedSessionClients.headerItem().setText(0, "1") + self.verticalLayout_6.addWidget(self.loadedSessionClients) + self.loadedSessionDescriptionGroupBox = QtWidgets.QGroupBox(self.vSplitterProgramsLog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(2) + sizePolicy.setHeightForWidth(self.loadedSessionDescriptionGroupBox.sizePolicy().hasHeightForWidth()) + self.loadedSessionDescriptionGroupBox.setSizePolicy(sizePolicy) + self.loadedSessionDescriptionGroupBox.setObjectName("loadedSessionDescriptionGroupBox") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.loadedSessionDescriptionGroupBox) + self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_2.setSpacing(0) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.loadedSessionDescription = QtWidgets.QPlainTextEdit(self.loadedSessionDescriptionGroupBox) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.loadedSessionDescription.sizePolicy().hasHeightForWidth()) + self.loadedSessionDescription.setSizePolicy(sizePolicy) + self.loadedSessionDescription.setObjectName("loadedSessionDescription") + self.verticalLayout_2.addWidget(self.loadedSessionDescription) + self.verticalLayout_4.addWidget(self.vSplitterProgramsLog) + self.detailedStackedWidget.addWidget(self.stack_loaded_session) + self.horizontalLayout.addWidget(self.detailedStackedWidget) + self.tabbyCat.addTab(self.tab_detailed, "") + self.tab_information = QtWidgets.QWidget() + self.tab_information.setObjectName("tab_information") + self.tabbyCat.addTab(self.tab_information, "") + self.verticalLayout.addWidget(self.tabbyCat) + MainWindow.setCentralWidget(self.centralwidget) + self.statusbar = QtWidgets.QStatusBar(MainWindow) + self.statusbar.setObjectName("statusbar") + MainWindow.setStatusBar(self.statusbar) + self.menubar = QtWidgets.QMenuBar(MainWindow) + self.menubar.setGeometry(QtCore.QRect(0, 0, 953, 20)) + self.menubar.setObjectName("menubar") + self.menuControl = QtWidgets.QMenu(self.menubar) + self.menuControl.setObjectName("menuControl") + self.menuSession = QtWidgets.QMenu(self.menubar) + self.menuSession.setObjectName("menuSession") + self.menuClientNameId = QtWidgets.QMenu(self.menubar) + self.menuClientNameId.setObjectName("menuClientNameId") + MainWindow.setMenuBar(self.menubar) + self.actionMenuQuit = QtWidgets.QAction(MainWindow) + self.actionMenuQuit.setObjectName("actionMenuQuit") + self.actionAbout = QtWidgets.QAction(MainWindow) + self.actionAbout.setObjectName("actionAbout") + self.actionManual = QtWidgets.QAction(MainWindow) + self.actionManual.setObjectName("actionManual") + self.actionHide_in_System_Tray = QtWidgets.QAction(MainWindow) + self.actionHide_in_System_Tray.setObjectName("actionHide_in_System_Tray") + self.actionSessionAddClient = QtWidgets.QAction(MainWindow) + self.actionSessionAddClient.setObjectName("actionSessionAddClient") + self.actionSessionSave = QtWidgets.QAction(MainWindow) + self.actionSessionSave.setObjectName("actionSessionSave") + self.actionSessionSaveAs = QtWidgets.QAction(MainWindow) + self.actionSessionSaveAs.setObjectName("actionSessionSaveAs") + self.actionSessionSaveAndClose = QtWidgets.QAction(MainWindow) + self.actionSessionSaveAndClose.setObjectName("actionSessionSaveAndClose") + self.actionSessionAbort = QtWidgets.QAction(MainWindow) + self.actionSessionAbort.setObjectName("actionSessionAbort") + self.actionClientStop = QtWidgets.QAction(MainWindow) + self.actionClientStop.setObjectName("actionClientStop") + self.actionClientResume = QtWidgets.QAction(MainWindow) + self.actionClientResume.setObjectName("actionClientResume") + self.actionClientSave_separately = QtWidgets.QAction(MainWindow) + self.actionClientSave_separately.setObjectName("actionClientSave_separately") + self.actionClientRemove = QtWidgets.QAction(MainWindow) + self.actionClientRemove.setObjectName("actionClientRemove") + self.actionClientToggleVisible = QtWidgets.QAction(MainWindow) + self.actionClientToggleVisible.setObjectName("actionClientToggleVisible") + self.actionShow_All_Clients = QtWidgets.QAction(MainWindow) + self.actionShow_All_Clients.setObjectName("actionShow_All_Clients") + self.actionHide_All_Clients = QtWidgets.QAction(MainWindow) + self.actionHide_All_Clients.setObjectName("actionHide_All_Clients") + self.actionRebuild_Program_Database = QtWidgets.QAction(MainWindow) + self.actionRebuild_Program_Database.setObjectName("actionRebuild_Program_Database") + self.actionClientRename = QtWidgets.QAction(MainWindow) + self.actionClientRename.setObjectName("actionClientRename") + self.menuControl.addAction(self.actionRebuild_Program_Database) + self.menuControl.addAction(self.actionHide_in_System_Tray) + self.menuControl.addAction(self.actionMenuQuit) + self.menuSession.addAction(self.actionSessionAddClient) + self.menuSession.addAction(self.actionSessionSave) + self.menuSession.addAction(self.actionSessionSaveAs) + self.menuSession.addAction(self.actionSessionSaveAndClose) + self.menuSession.addAction(self.actionSessionAbort) + self.menuSession.addSeparator() + self.menuSession.addAction(self.actionShow_All_Clients) + self.menuSession.addAction(self.actionHide_All_Clients) + self.menuClientNameId.addAction(self.actionClientRename) + self.menuClientNameId.addAction(self.actionClientToggleVisible) + self.menuClientNameId.addAction(self.actionClientSave_separately) + self.menuClientNameId.addAction(self.actionClientStop) + self.menuClientNameId.addAction(self.actionClientResume) + self.menuClientNameId.addSeparator() + self.menuClientNameId.addAction(self.actionClientRemove) + self.menubar.addAction(self.menuControl.menuAction()) + self.menubar.addAction(self.menuSession.menuAction()) + self.menubar.addAction(self.menuClientNameId.menuAction()) + + self.retranslateUi(MainWindow) + self.tabbyCat.setCurrentIndex(0) + self.quickStackedWidget.setCurrentIndex(1) + self.detailedStackedWidget.setCurrentIndex(0) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "Argodejo")) + self.quickNewSession.setText(_translate("MainWindow", "Start New Session")) + self.quickSessionNameLineEdit.setText(_translate("MainWindow", "Session Name Goes Here")) + self.quickSaveOpenSession.setText(_translate("MainWindow", "Save")) + self.quickCloseOpenSession.setText(_translate("MainWindow", "Save and Close")) + self.quickSessionNotesGroupBox.setTitle(_translate("MainWindow", "Session Notes")) + self.quickSessionClientsListWidget.setSortingEnabled(True) + self.tabbyCat.setTabText(self.tabbyCat.indexOf(self.tab_quick), _translate("MainWindow", "Quick View")) + self.button_new_session.setText(_translate("MainWindow", "New")) + self.button_load_selected_session.setText(_translate("MainWindow", "Load Selected")) + self.checkBoxNested.setText(_translate("MainWindow", "Tree View")) + self.session_programs.setTitle(_translate("MainWindow", "Double-click to load program")) + self.session_loaded.setTitle(_translate("MainWindow", "In current session")) + self.loadedSessionDescriptionGroupBox.setTitle(_translate("MainWindow", "Session Notes")) + self.tabbyCat.setTabText(self.tabbyCat.indexOf(self.tab_detailed), _translate("MainWindow", "Full View")) + self.tabbyCat.setTabText(self.tabbyCat.indexOf(self.tab_information), _translate("MainWindow", "Information")) + self.menuControl.setTitle(_translate("MainWindow", "Control")) + self.menuSession.setTitle(_translate("MainWindow", "SessionName")) + self.menuClientNameId.setTitle(_translate("MainWindow", "ClientNameId")) + self.actionMenuQuit.setText(_translate("MainWindow", "Quit")) + self.actionMenuQuit.setShortcut(_translate("MainWindow", "Ctrl+Shift+Q")) + self.actionAbout.setText(_translate("MainWindow", "About")) + self.actionManual.setText(_translate("MainWindow", "Manual")) + self.actionHide_in_System_Tray.setText(_translate("MainWindow", "Hide in System Tray")) + self.actionHide_in_System_Tray.setShortcut(_translate("MainWindow", "Ctrl+Q")) + self.actionSessionAddClient.setText(_translate("MainWindow", "Add Client (Prompt)")) + self.actionSessionAddClient.setShortcut(_translate("MainWindow", "A")) + self.actionSessionSave.setText(_translate("MainWindow", "Save")) + self.actionSessionSave.setShortcut(_translate("MainWindow", "Ctrl+S")) + self.actionSessionSaveAs.setText(_translate("MainWindow", "Save As")) + self.actionSessionSaveAs.setShortcut(_translate("MainWindow", "Ctrl+Shift+S")) + self.actionSessionSaveAndClose.setText(_translate("MainWindow", "Save and Close")) + self.actionSessionSaveAndClose.setShortcut(_translate("MainWindow", "Ctrl+W")) + self.actionSessionAbort.setText(_translate("MainWindow", "Abort")) + self.actionSessionAbort.setShortcut(_translate("MainWindow", "Ctrl+Shift+W")) + self.actionClientStop.setText(_translate("MainWindow", "Stop")) + self.actionClientStop.setShortcut(_translate("MainWindow", "Alt+O")) + self.actionClientResume.setText(_translate("MainWindow", "Resume")) + self.actionClientResume.setShortcut(_translate("MainWindow", "Alt+R")) + self.actionClientSave_separately.setText(_translate("MainWindow", "Save separately")) + self.actionClientSave_separately.setShortcut(_translate("MainWindow", "Alt+S")) + self.actionClientRemove.setText(_translate("MainWindow", "Remove")) + self.actionClientRemove.setShortcut(_translate("MainWindow", "Alt+X")) + self.actionClientToggleVisible.setText(_translate("MainWindow", "Toggle Visible")) + self.actionClientToggleVisible.setShortcut(_translate("MainWindow", "Alt+T")) + self.actionShow_All_Clients.setText(_translate("MainWindow", "Show All Clients")) + self.actionHide_All_Clients.setText(_translate("MainWindow", "Hide All Clients")) + self.actionRebuild_Program_Database.setText(_translate("MainWindow", "Rebuild Program Database")) + self.actionClientRename.setText(_translate("MainWindow", "Rename")) + self.actionClientRename.setShortcut(_translate("MainWindow", "F2")) diff --git a/qtgui/designer/mainwindow.ui b/qtgui/designer/mainwindow.ui new file mode 100644 index 0000000..1cd30f1 --- /dev/null +++ b/qtgui/designer/mainwindow.ui @@ -0,0 +1,670 @@ + + + MainWindow + + + + 0 + 0 + 953 + 763 + + + + Argodejo + + + + + + + 0 + + + + Quick View + + + + + + 1 + + + + + + + Start New Session + + + true + + + true + + + + + + + true + + + + + 0 + 0 + 98 + 28 + + + + + + + Qt::Vertical + + + + 20 + 586 + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Session Name Goes Here + + + Qt::AlignCenter + + + + + + + + 0 + + + 0 + + + + + Save + + + + + + + Save and Close + + + + + + + + + + Qt::Vertical + + + + + 0 + 1 + + + + Session Notes + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + + + + + + 0 + 6 + + + + QListView::IconMode + + + true + + + + + + + + + + + + + Full View + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + 6 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + + + New + + + + + + + Load Selected + + + + + + + Tree View + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + 1 + + + + + + + + + + 0 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Qt::Vertical + + + + + 0 + 7 + + + + Qt::Horizontal + + + false + + + + + 0 + 7 + + + + Double-click to load program + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QAbstractItemView::DragDrop + + + QAbstractItemView::NoSelection + + + + 64 + 64 + + + + QAbstractItemView::ScrollPerPixel + + + QAbstractItemView::ScrollPerPixel + + + + 1 + + + + + + + + + In current session + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 3 + + + + QAbstractItemView::DropOnly + + + true + + + QAbstractItemView::ScrollPerPixel + + + QAbstractItemView::ScrollPerPixel + + + + 1 + + + + + + + + + + + 0 + 2 + + + + Session Notes + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + + + + + + + + + + + + + Information + + + + + + + + + + + 0 + 0 + 953 + 20 + + + + + Control + + + + + + + + SessionName + + + + + + + + + + + + + ClientNameId + + + + + + + + + + + + + + + + Quit + + + Ctrl+Shift+Q + + + + + About + + + + + Manual + + + + + Hide in System Tray + + + Ctrl+Q + + + + + Add Client (Prompt) + + + A + + + + + Save + + + Ctrl+S + + + + + Save As + + + Ctrl+Shift+S + + + + + Save and Close + + + Ctrl+W + + + + + Abort + + + Ctrl+Shift+W + + + + + Stop + + + Alt+O + + + + + Resume + + + Alt+R + + + + + Save separately + + + Alt+S + + + + + Remove + + + Alt+X + + + + + Toggle Visible + + + Alt+T + + + + + Show All Clients + + + + + Hide All Clients + + + + + Rebuild Program Database + + + + + Rename + + + F2 + + + + + + diff --git a/qtgui/designer/newsession.py b/qtgui/designer/newsession.py new file mode 100644 index 0000000..2bb2624 --- /dev/null +++ b/qtgui/designer/newsession.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'newsession.ui' +# +# Created by: PyQt5 UI code generator 5.14.1 +# +# WARNING! All changes made in this file will be lost! + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_NewSession(object): + def setupUi(self, NewSession): + NewSession.setObjectName("NewSession") + NewSession.resize(448, 222) + self.verticalLayout = QtWidgets.QVBoxLayout(NewSession) + self.verticalLayout.setObjectName("verticalLayout") + self.nameGroupBox = QtWidgets.QGroupBox(NewSession) + self.nameGroupBox.setObjectName("nameGroupBox") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.nameGroupBox) + self.verticalLayout_3.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_3.setSpacing(0) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.verticalLayout.addWidget(self.nameGroupBox) + self.checkBoxJack = QtWidgets.QCheckBox(NewSession) + self.checkBoxJack.setChecked(True) + self.checkBoxJack.setObjectName("checkBoxJack") + self.verticalLayout.addWidget(self.checkBoxJack) + self.checkBoxData = QtWidgets.QCheckBox(NewSession) + self.checkBoxData.setChecked(True) + self.checkBoxData.setObjectName("checkBoxData") + self.verticalLayout.addWidget(self.checkBoxData) + self.buttonBox = QtWidgets.QDialogButtonBox(NewSession) + self.buttonBox.setOrientation(QtCore.Qt.Horizontal) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) + self.buttonBox.setObjectName("buttonBox") + self.verticalLayout.addWidget(self.buttonBox) + + self.retranslateUi(NewSession) + self.buttonBox.accepted.connect(NewSession.accept) + self.buttonBox.rejected.connect(NewSession.reject) + QtCore.QMetaObject.connectSlotsByName(NewSession) + + def retranslateUi(self, NewSession): + _translate = QtCore.QCoreApplication.translate + NewSession.setWindowTitle(_translate("NewSession", "Dialog")) + self.nameGroupBox.setTitle(_translate("NewSession", "New Session Name")) + self.checkBoxJack.setText(_translate("NewSession", "Save JACK Connections\n" +"(adds clients \'nsm-jack\')")) + self.checkBoxData.setText(_translate("NewSession", "Client Renaming and Session Notes\n" +"(adds client \'nsm-data\')")) diff --git a/qtgui/designer/newsession.ui b/qtgui/designer/newsession.ui new file mode 100644 index 0000000..17ee03e --- /dev/null +++ b/qtgui/designer/newsession.ui @@ -0,0 +1,110 @@ + + + NewSession + + + + 0 + 0 + 448 + 222 + + + + Dialog + + + + + + New Session Name + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Save JACK Connections +(adds clients 'nsm-jack') + + + true + + + + + + + Client Renaming and Session Notes +(adds client 'nsm-data') + + + true + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + NewSession + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + NewSession + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/qtgui/designer/projectname.py b/qtgui/designer/projectname.py new file mode 100644 index 0000000..40c3864 --- /dev/null +++ b/qtgui/designer/projectname.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'projectname.ui' +# +# Created by: PyQt5 UI code generator 5.14.1 +# +# WARNING! All changes made in this file will be lost! + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_ProjectName(object): + def setupUi(self, ProjectName): + ProjectName.setObjectName("ProjectName") + ProjectName.resize(537, 84) + self.gridLayout = QtWidgets.QGridLayout(ProjectName) + self.gridLayout.setObjectName("gridLayout") + self.buttonBox = QtWidgets.QDialogButtonBox(ProjectName) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) + self.buttonBox.setObjectName("buttonBox") + self.gridLayout.addWidget(self.buttonBox, 1, 1, 1, 1) + self.labelError = QtWidgets.QLabel(ProjectName) + self.labelError.setObjectName("labelError") + self.gridLayout.addWidget(self.labelError, 2, 0, 1, 1) + self.labelDescription = QtWidgets.QLabel(ProjectName) + self.labelDescription.setObjectName("labelDescription") + self.gridLayout.addWidget(self.labelDescription, 0, 0, 1, 1) + self.name = QtWidgets.QLineEdit(ProjectName) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(3) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.name.sizePolicy().hasHeightForWidth()) + self.name.setSizePolicy(sizePolicy) + self.name.setObjectName("name") + self.gridLayout.addWidget(self.name, 1, 0, 1, 1) + + self.retranslateUi(ProjectName) + QtCore.QMetaObject.connectSlotsByName(ProjectName) + + def retranslateUi(self, ProjectName): + _translate = QtCore.QCoreApplication.translate + ProjectName.setWindowTitle(_translate("ProjectName", "Form")) + self.labelError.setText(_translate("ProjectName", "Error Message")) + self.labelDescription.setText(_translate("ProjectName", "Choose a project name. Use / for subdirectories")) diff --git a/qtgui/designer/projectname.ui b/qtgui/designer/projectname.ui new file mode 100644 index 0000000..56a7025 --- /dev/null +++ b/qtgui/designer/projectname.ui @@ -0,0 +1,52 @@ + + + ProjectName + + + + 0 + 0 + 537 + 84 + + + + Form + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + Error Message + + + + + + + Choose a project name. Use / for subdirectories + + + + + + + + 3 + 0 + + + + + + + + + diff --git a/qtgui/eventloop.py b/qtgui/eventloop.py new file mode 100644 index 0000000..f1590d6 --- /dev/null +++ b/qtgui/eventloop.py @@ -0,0 +1,75 @@ +#! /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 ), +more specifically its template base application. + +The Template Base 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") + +from PyQt5 import QtCore + +class EventLoop(object): + def __init__(self): + """The loop for all things GUI and controlling the GUI (e.g. by a control midi in port) + + By default use fastConnect. + + 0 ms means "if there is time". 10ms-20ms is smooth. 100ms is still ok. + Influences everything. Control Midi In Latency, playback cursor scrolling smoothnes etc. + + But not realtime. This is not the realtime loop. Converting midi into instrument sounds + or playing back sequenced midi data is not handled by this loop at all. + + Creating a non-qt class for the loop is an abstraction layer that enables the engine to + work without modification for non-gui situations. In this case it will use its own loop, + like python async etc. + + A qt event loop needs the qt-app started. Otherwise it will not run. + We init the event loop outside of main but call start from the mainWindow. + """ + + self.fastLoop = QtCore.QTimer() + self.slowLoop = QtCore.QTimer() + + def fastConnect(self, function): + self.fastLoop.timeout.connect(function) + + def slowConnect(self, function): + self.slowLoop.timeout.connect(function) + + def fastDisconnect(self, function): + """The function must be the exact instance that was registered""" + self.fastLoop.timeout.disconnect(function) + + def slowDisconnect(self, function): + """The function must be the exact instance that was registered""" + self.slowLoop.timeout.disconnect(function) + + def start(self): + """The event loop MUST be started after the Qt Application instance creation""" + logger.info("Starting fast qt event loop") + self.fastLoop.start(20) + logger.info("Starting slow qt event loop") + self.slowLoop.start(200) + + def stop(self): + logger.info("Stopping fast qt event loop") + self.fastLoop.stop() + logger.info("Stopping slow qt event loop") + self.slowLoop.stop() diff --git a/qtgui/helper.py b/qtgui/helper.py new file mode 100644 index 0000000..29a1dd8 --- /dev/null +++ b/qtgui/helper.py @@ -0,0 +1,181 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2017, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +This file is part of Laborejo ( https://www.laborejo.org ) + +Laborejo 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 . +""" + +from PyQt5 import QtCore, QtGui, QtWidgets +from hashlib import md5 + +def iconFromString(st, size=128): + px = QtGui.QPixmap(size,size) + color = stringToColor(st) + px.fill(color) + return QtGui.QIcon(px) + + +def sizeof_fmt(num, suffix='B'): + """https://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size""" + for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']: + if abs(num) < 1024.0: + return "%3.1f%s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f%s%s" % (num, 'Yi', suffix) + +def stringToColor(st): + """Convert a string to QColor. Same string, same color + Is used for group coloring""" + if st: + c = md5(st.encode()).hexdigest() + return QtGui.QColor(int(c[0:9],16) % 255, int(c[10:19],16) % 255, int(c[20:29],16)% 255, 255) + else: + return QtGui.QColor(255,255,255,255) #Return White + +def invertColor(qcolor): + r = 255 - qcolor.red() + g = 255 - qcolor.green() + b = 255 - qcolor.blue() + return QtGui.QColor(r, g, b, qcolor.alpha()) + +def removeInstancesFromScene(qtGraphicsClass): + """"Remove all instances of a qt class that implements .instances=[] from the QGraphicsScene. + Don't use for items or anything in the notation view. This is used by the likes of the conductor + only since they exist only once and gets redrawn completely each time.""" + for instance in qtGraphicsClass.instances: + instance.setParentItem(None) + instance.scene().removeWhenIdle(instance) + qtGraphicsClass.instances = [] + +def callContextMenu(listOfLabelsAndFunctions): + menu = QtWidgets.QMenu() + + for text, function in listOfLabelsAndFunctions: + if text == "separator": + menu.addSeparator() + 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 stretchLine(qGraphicsLineItem, factor): + line = qGraphicsLineItem.line() + line.setLength(line.length() * factor) + qGraphicsLineItem.setLine(line) + +def stretchRect(qGraphicsRectItem, factor): + r = qGraphicsRectItem.rect() + r.setRight(r.right() * factor) + qGraphicsRectItem.setRect(r) + +class QHLine(QtWidgets.QFrame): + def __init__(self): + super().__init__() + self.setFrameShape(QtWidgets.QFrame.HLine) + self.setFrameShadow(QtWidgets.QFrame.Sunken) + +def setPaletteAndFont(qtApp): + """Set our programs color + This is in its own function because it is a annoying to scroll by that in init. + http://doc.qt.io/qt-5/qpalette.html""" + fPalBlue = QtGui.QPalette() + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Window, QtGui.QColor(32, 35, 39)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Window, QtGui.QColor(37, 40, 45)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Window, QtGui.QColor(37, 40, 45)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.WindowText, QtGui.QColor(89, 95, 104)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.WindowText, QtGui.QColor(223, 237, 255)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.WindowText, QtGui.QColor(223, 237, 255)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Base, QtGui.QColor(48, 53, 60)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Base, QtGui.QColor(55, 61, 69)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Base, QtGui.QColor(55, 61, 69)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.AlternateBase, QtGui.QColor(60, 64, 67)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.AlternateBase, QtGui.QColor(69, 73, 77)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.AlternateBase, QtGui.QColor(69, 73, 77)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ToolTipBase, QtGui.QColor(182, 193, 208)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.ToolTipBase, QtGui.QColor(182, 193, 208)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.ToolTipBase, QtGui.QColor(182, 193, 208)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ToolTipText, QtGui.QColor(42, 44, 48)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.ToolTipText, QtGui.QColor(42, 44, 48)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.ToolTipText, QtGui.QColor(42, 44, 48)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Text, QtGui.QColor(96, 103, 113)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Text, QtGui.QColor(210, 222, 240)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Text, QtGui.QColor(210, 222, 240)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Button, QtGui.QColor(51, 55, 62)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Button, QtGui.QColor(59, 63, 71)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Button, QtGui.QColor(59, 63, 71)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ButtonText, QtGui.QColor(98, 104, 114)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.ButtonText, QtGui.QColor(210, 222, 240)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.ButtonText, QtGui.QColor(210, 222, 240)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.BrightText, QtGui.QColor(255, 255, 255)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.BrightText, QtGui.QColor(255, 255, 255)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.BrightText, QtGui.QColor(255, 255, 255)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Light, QtGui.QColor(59, 64, 72)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Light, QtGui.QColor(63, 68, 76)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Light, QtGui.QColor(63, 68, 76)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Midlight, QtGui.QColor(48, 52, 59)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Midlight, QtGui.QColor(51, 56, 63)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Midlight, QtGui.QColor(51, 56, 63)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Dark, QtGui.QColor(18, 19, 22)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Dark, QtGui.QColor(20, 22, 25)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Dark, QtGui.QColor(20, 22, 25)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Mid, QtGui.QColor(28, 30, 34)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Mid, QtGui.QColor(32, 35, 39)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Mid, QtGui.QColor(32, 35, 39)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Shadow, QtGui.QColor(13, 14, 16)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Shadow, QtGui.QColor(15, 16, 18)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Shadow, QtGui.QColor(15, 16, 18)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Highlight, QtGui.QColor(32, 35, 39)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Highlight, QtGui.QColor(14, 14, 17)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Highlight, QtGui.QColor(27, 28, 33)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.HighlightedText, QtGui.QColor(89, 95, 104)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.HighlightedText, QtGui.QColor(217, 234, 253)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.HighlightedText, QtGui.QColor(223, 237, 255)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Link, QtGui.QColor(79, 100, 118)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Link, QtGui.QColor(156, 212, 255)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Link, QtGui.QColor(156, 212, 255)) + + fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.LinkVisited, QtGui.QColor(51, 74, 118)) + fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.LinkVisited, QtGui.QColor(64, 128, 255)) + fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.LinkVisited, QtGui.QColor(64, 128, 255)) + + qtApp.setPalette(fPalBlue) + + font = QtGui.QFont("DejaVu Sans", 10) + font.setPixelSize(12) + qtApp.setFont(font) + return fPalBlue diff --git a/qtgui/mainwindow.py b/qtgui/mainwindow.py new file mode 100644 index 0000000..0d8ac2f --- /dev/null +++ b/qtgui/mainwindow.py @@ -0,0 +1,448 @@ +#! /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 ). + + +The Template Base 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 +from sys import argv as sysargv +from sys import exit as sysexit + +#Third Party +from PyQt5 import QtCore, QtGui, QtWidgets + +#Engine +from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. +from engine.start import PATHS +import engine.api as api #This loads the engine and starts a session. + +#Qt +from .systemtray import SystemTray +from .eventloop import EventLoop +from .designer.mainwindow import Ui_MainWindow +from .helper import setPaletteAndFont +from .helper import iconFromString +from .sessiontreecontroller import SessionTreeController +from .opensessioncontroller import OpenSessionController +from .quicksessioncontroller import QuickSessionController +from .quickopensessioncontroller import QuickOpenSessionController +from .projectname import ProjectNameWidget +from .addclientprompt import askForExecutable, updateWordlist +from .waitdialog import WaitDialog + + +api.eventLoop = EventLoop() + +#Construct QAppliction before constantsAndCOnfigs, which has the fontDB +QtGui.QGuiApplication.setDesktopSettingsAware(False) #We need our own font so the user interface stays predictable +QtGui.QGuiApplication.setDesktopFileName(PATHS["desktopfile"]) +qtApp = QtWidgets.QApplication(sysargv) + +#Setup the translator before classes are set up. Otherwise we can't use non-template translation. +#to test use LANGUAGE=de_DE.UTF-8 . not LANG= +language = QtCore.QLocale().languageToString(QtCore.QLocale().language()) +logger.info("{}: Language set to {}".format(METADATA["name"], language)) +if language in METADATA["supportedLanguages"]: + translator = QtCore.QTranslator() + translator.load(METADATA["supportedLanguages"][language], ":/template/translations/") #colon to make it a resource URL + qtApp.installTranslator(translator) + +else: + """silently fall back to English by doing nothing""" + + +def nothing(*args): + pass + +class RecentlyOpenedSessions(object): + """Class to make it easier handle recently opened session with qt settings, type conversions + limiting the size of the list and uniqueness""" + + def __init__(self): + self.data = [] + + def load(self, dataFromQtSettings): + """Handle qt settings load, working around everything it has""" + if dataFromQtSettings: + for name in dataFromQtSettings: + self.add(name) + + def add(self, nsmSessionName:str): + if nsmSessionName in self.data: + return + + self.data.append(nsmSessionName) + if len(self.data) > 3: + self.data.pop(0) + assert len(self.data) <= 3, len(self.data) + + + def get(self): + sessionList = api.sessionList() + self.data = [n for n in self.data if n in sessionList] + return self.data + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.qtApp = qtApp + self.qtApp.setWindowIcon(QtGui.QIcon("icon.png")) #non-template part of the program + self.qtApp.setApplicationName(f"{METADATA['name']}") + self.qtApp.setApplicationDisplayName(f"{METADATA['name']}") + logger.info("Init MainWindow") + + #Set up the user interface from Designer and other widgets + self.ui = Ui_MainWindow() + self.ui.setupUi(self) + self.fPalBlue = setPaletteAndFont(self.qtApp) + + self.sessionController = SessionController(mainWindow=self) + self.systemTray = SystemTray(mainWindow=self) + self.connectMenu() + + self.recentlyOpenedSessions = RecentlyOpenedSessions() + self.programIcons = {} #executableName:QIcon. Filled by self.updateProgramDatabase + + #Menu + self.ui.actionRebuild_Program_Database.triggered.connect(self.updateProgramDatabase) + + #Api Callbacks + api.callbacks.sessionClosed.append(self.reactCallback_sessionClosed) + api.callbacks.sessionOpenReady.append(self.reactCallback_sessionOpen) + api.callbacks.singleInstanceActivateWindow.append(self.activateAndRaise) + + #Starting the engine sends initial GUI data. Every window and widget must be ready to receive callbacks here + api.eventLoop.start() + api.startEngine() + logger.info("Show MainWindow") + self.restoreWindowSettings() #includes show/hide + + + #Handle the application data cache. If not present instruct the engine to build one. + #This is also needed by the prompt in sessionController + settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) + if settings.contains("programDatabase"): + listOfDicts = settings.value("programDatabase") + api.setSystemsPrograms(listOfDicts) + logger.info("Restored program database from qt cache to engine") + self._updateGUIWithCachedPrograms() + else: #First or fresh start + #A single shot timer with 0 durations is executed only after the app starts, thus the main window is ready. + QtCore.QTimer.singleShot(0, self.updateProgramDatabase) #includes self._updateGUIWithCachedPrograms() + + if not self.isVisible(): + text = QtCore.QCoreApplication.translate("mainWindow", "Argodejo ready") + self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal. + + qtApp.exec_() + #No code after exec_ + + def hideEvent(self, event): + if self.systemTray.available: + super().hideEvent(event) + else: + event.ignore() + + def activateAndRaise(self): + self.toggleVisible(force=True) + getattr(self, "raise")() #raise is python syntax. Can't use that directly + self.activateWindow() + text = QtCore.QCoreApplication.translate("activateAndRaise", "Another GUI tried to launch.") + self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal. + + def _updateGUIWithCachedPrograms(self): + logger.info("Updating entire program with cached program lists") + updateWordlist() #addclientprompt.py + self._updateIcons() + self.sessionController.openSessionController.launcherTable.buildPrograms() + self.sessionController.quickOpenSessionController.buildCleanStarterClients(nsmSessionExportDict={}) #wants a dict parameter for callback compatibility, but doesn't use it + + def _updateIcons(self): + logger.info("Creating icon database") + programs = api.getSystemPrograms() + self.programIcons.clear() + for entry in programs: + exe = entry["argodejoExec"] + if "icon" in entry: + icon = QtGui.QIcon.fromTheme(entry["icon"]) + else: + icon = QtGui.QIcon.fromTheme(exe) + if icon.isNull(): + icon = iconFromString(exe) + self.programIcons[exe] = icon + + def updateProgramDatabase(self): + """Display a progress-dialog that waits for the database to be build. + Automatically called on first start or when instructed by the user""" + text = QtCore.QCoreApplication.translate("updateProgramDatabase", "Updating Program Database") + informativeText = QtCore.QCoreApplication.translate("updateProgramDatabase", "Thank you for your patience.") + title = QtCore.QCoreApplication.translate("updateProgramDatabase", "Updating") + settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) + settings.remove("programDatabase") + + WaitDialog(self, title, text, informativeText, api.buildSystemPrograms) + settings.setValue("programDatabase", api.getSystemPrograms()) + self._updateGUIWithCachedPrograms() + + def reactCallback_sessionClosed(self): + self.setWindowTitle("") + + def reactCallback_sessionOpen(self, nsmSessionExportDict): + self.setWindowTitle(nsmSessionExportDict["nsmSessionName"]) + self.recentlyOpenedSessions.add(nsmSessionExportDict["nsmSessionName"]) + + def toggleVisible(self, force:bool=None): + if force: + newState = force + else: + newState = not self.isVisible() + + if newState: + logger.info("Show") + self.restoreWindowSettings() + self.show() + else: + logger.info("Hide") + self.storeWindowSettings() + self.hide() + #self.systemTray.buildContextMenu() #Don't. This crashes Qt through some delayed execution of who knows what. Workaround: tray context menu now say "Show/Hide" and not only the actual state. + + def _askBeforeQuit(self, nsmSessionName): + """If you quit while in a session ask what to do. + The TrayIcon context menu uses different functions and directly acts, without a question""" + text = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit but session {} still open").format(nsmSessionName) + informativeText = QtCore.QCoreApplication.translate("AskBeforeQuit", "Do you want to save?") + title = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit") + + box = QtWidgets.QMessageBox() + box.setWindowFlag(QtCore.Qt.Popup, True) + box.setIcon(box.Warning) + box.setText(text) + box.setWindowTitle(title) + box.setInformativeText(informativeText) + + stay = box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Don't Quit"), box.RejectRole) #0 + box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Save"), box.YesRole) #1 + box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Discard Changes"), box.DestructiveRole) #2 + box.setDefaultButton(stay) + ret = box.exec() #Return values are NOT the button roles. + + if ret == 2: + logger.info("Quit: Don't save.") + api.sessionAbort(blocking=True) + return True + elif ret == 1: + logger.info("Quit: Close and Save. Waiting for clients to close.") + api.sessionClose(blocking=True) + return True + else: #Escape, window close through WM etc. + logger.info("Quit: Changed your mind, stay in session.") + return False + + def abortAndQuit(self): + """For the context menu. A bit + A bit redundant, but that is ok :)""" + api.sessionAbort(blocking=True) + self._callSysExit() + + def closeAndQuit(self): + api.sessionClose(blocking=True) + self._callSysExit() + + def _callSysExit(self): + """The process of quitting + After sysexit the atexit handler gets called. + That closes nsmd, if we started ourselves. + """ + self.storeWindowSettings() + sysexit(0) #directly afterwards @atexit is handled, but this function does not return. + logging.error("Code executed after sysexit. This message should not have been visible.") + + def menuRealQuit(self): + """Called by the menu. + The TrayIcon provides another method of quitting that does not call this function, + but it will call _actualQuit. + """ + if api.currentSession(): + result = self._askBeforeQuit(api.currentSession()) + else: + result = True + + if result: + self.storeWindowSettings() + self._callSysExit() + + + def closeEvent(self, event): + """Window manager close. + Ignore. We use it to send the GUI into hiding.""" + event.ignore() + self.toggleVisible(force=False) + + def connectMenu(self): + #Control + self.ui.actionHide_in_System_Tray.triggered.connect(lambda: self.toggleVisible(force=False)) + self.ui.actionMenuQuit.triggered.connect(self.menuRealQuit) + + def storeWindowSettings(self): + """Window state is not saved in the real save file. That would lead to portability problems + between computers, like different screens and resolutions. + + For convenience that means we just use the damned qt settings and save wherever qt wants. + + bottom line: get a tiling window manager. + """ + settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) + settings.setValue("geometry", self.saveGeometry()) + settings.setValue("windowState", self.saveState()) + settings.setValue("visible", self.isVisible()) + settings.setValue("recentlyOpenedSessions", self.recentlyOpenedSessions.get()) + settings.setValue("tab", self.ui.tabbyCat.currentIndex()) + + def restoreWindowSettings(self): + """opposite of storeWindowSettings. Read there.""" + settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) + + actions = { + "geometry":self.restoreGeometry, + "windowState":self.restoreState, + "recentlyOpenedSessions":self.recentlyOpenedSessions.load, + "visible":self.recentlyOpenedSessions.load, + "tab": lambda i: self.ui.tabbyCat.setCurrentIndex(int(i)), + } + + for key in settings.allKeys(): + if key in actions: #if not it doesn't matter. this is all uncritical. + actions[key](settings.value(key)) + + if self.systemTray.available and settings.contains("visible") and settings.value("visible") == "false": + self.setVisible(False) + else: + self.setVisible(True) #This is also the default state if there is no config + + +class SessionController(object): + """Controls the StackWidget that contains the Session Tree, Open Session/Client and their + quick and easy variants. + Can be controlled from up and down the hierarchy. + + While all tabs are open at the same time for simplicity we hide the menus when in quick-view. + """ + + def __init__(self, mainWindow): + super().__init__() + self.mainWindow = mainWindow + self.ui = self.mainWindow.ui + self.sessionTreeController = SessionTreeController(mainWindow=mainWindow) + self.openSessionController = OpenSessionController(mainWindow=mainWindow) + + self.quickSessionController = QuickSessionController(mainWindow=mainWindow) + self.quickOpenSessionController = QuickOpenSessionController(mainWindow=mainWindow) + self._connectMenu() + + #Callbacks + #api.callbacks.sessionOpenReady.append(lambda nsmSessionExportDict: self._switch("open")) #When loading ist done. This takes a while when non-nsm clients are in the session + api.callbacks.sessionClosed.append(lambda: self._setMenuEnabled(None)) + api.callbacks.sessionClosed.append(lambda: self._switch("choose")) #The rest is handled by the widget itself. It keeps itself updated, no matter if visible or not. + api.callbacks.sessionOpenReady.append(lambda nsmSessionExportDict: self._setMenuEnabled(nsmSessionExportDict)) + api.callbacks.sessionOpenLoading.append(lambda nsmSessionExportDict: self._switch("open")) + + #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._setMenuEnabled(None)) + self.mainWindow.ui.actionSessionSaveAndClose.triggered.connect(lambda: self._setMenuEnabled(None)) + + #GUI signals + self.mainWindow.ui.tabbyCat.currentChanged.connect(self._activeTabChanged) + + self._activeTabChanged(self.mainWindow.ui.tabbyCat.currentIndex()) + + def _activeTabChanged(self, index:int): + """index 0 quick, 1 detailed, 2 info""" + if index == 1: #detailed + state = bool(api.currentSession()) + else: #quick and information and future tabs + state = False + + self.ui.menuClientNameId.menuAction().setVisible(state) #already deactivated + self.ui.menuSession.menuAction().setVisible(state) #already deactivated + + #It is not enough to disable the menu itself. Shortcuts will still work. We need the children! + for action in self.ui.menuSession.actions(): + action.setEnabled(state) + + if state and not self.openSessionController.clientTabe.clientsTreeWidget.currentItem(): + state = False #we wanted to activate, but there is no client selected. + for action in self.ui.menuClientNameId.actions(): + action.setEnabled(state) + + def _connectMenu(self): + #Session + #Only Active when a session is currently available + self.ui.actionSessionSave.triggered.connect(api.sessionSave) + self.ui.actionSessionAbort.triggered.connect(api.sessionAbort) + self.ui.actionSessionSaveAs.triggered.connect(self._reactMenu_SaveAs) #NSM "Duplicate" + self.ui.actionSessionSaveAndClose.triggered.connect(api.sessionClose) + self.ui.actionShow_All_Clients.triggered.connect(api.clientShowAll) + self.ui.actionHide_All_Clients.triggered.connect(api.clientHideAll) + self.ui.actionSessionAddClient.triggered.connect(lambda: askForExecutable(self.mainWindow)) #Prompt version + + def _reactMenu_SaveAs(self): + """Only when a session is open. + We could either check the session controller or the simple one for the name.""" + currentName = api.currentSession() + assert currentName + widget = ProjectNameWidget(parent=self.mainWindow, startwith=currentName+"-new") + if widget.result: + api.sessionSaveAs(widget.result) + + def _setMenuEnabled(self, nsmSessionExportDictOrNone): + """We receive the sessionDict or None""" + state = bool(nsmSessionExportDictOrNone) + if state: + self.ui.menuSession.setTitle(nsmSessionExportDictOrNone["nsmSessionName"]) + self.ui.menuSession.menuAction().setVisible(True) + self.ui.menuClientNameId.menuAction().setVisible(True) #session controller might disable that + else: + self.ui.menuSession.setTitle("Session") + self.ui.menuSession.menuAction().setVisible(False) + self.ui.menuClientNameId.menuAction().setVisible(False) #already deactivated + + #self.ui.menuSession.setEnabled(state) #It is not enough to disable the menu itself. Shortcuts will still work. + for action in self.ui.menuSession.actions(): + action.setEnabled(state) + + #Maybe the tab state overrules everything + self._activeTabChanged(self.mainWindow.ui.tabbyCat.currentIndex()) + + def _switch(self, page:str): + """Only called by the sub-controllers. + For example when an existing session gets opened""" + if page == "choose": + pageIndex = 0 + elif page == "open": + pageIndex = 1 + else: + raise ValueError(f"_switch accepts choose or open, not {page}") + + self.mainWindow.ui.detailedStackedWidget.setCurrentIndex(pageIndex) + self.mainWindow.ui.quickStackedWidget.setCurrentIndex(pageIndex) diff --git a/qtgui/opensessioncontroller.py b/qtgui/opensessioncontroller.py new file mode 100644 index 0000000..9e7c7e1 --- /dev/null +++ b/qtgui/opensessioncontroller.py @@ -0,0 +1,482 @@ +#! /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 ), +more specifically its template base application. + +The Template Base 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 + +#Engine +import engine.api as api + +#Qt +from .descriptiontextwidget import DescriptionController + +iconSize = QtCore.QSize(16,16) + +class ClientItem(QtWidgets.QTreeWidgetItem): + """ + 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) + + 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""" + 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 = QtCore.QCoreApplication.translate("OpenSession", "✔") + else: + t = QtCore.QCoreApplication.translate("OpenSession", "✖") + 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) + + programIcons = self.parentController.mainWindow.programIcons + assert programIcons + assert "executable" in clientDict, clientDict + if clientDict["executable"] in programIcons: + icon = programIcons[clientDict["executable"]] + self.setIcon(self.parentController.clientsTreeWidgetColumns.index("reportedName"), icon) #reported name is correct here. this is just the column. + + + #TODO: this should be an nsmd status. Check if excecutable exists. nsmd just reports "stopped", and worse: after a long timeout. + nameColumn = self.parentController.clientsTreeWidgetColumns.index("reportedName") + if not self.text(nameColumn): #Empty string because program not found + self.setText(nameColumn, clientDict["executable"]) + self.setText(self.parentController.clientsTreeWidgetColumns.index("lastStatus"), QtCore.QCoreApplication.translate("OpenSession", "(command not found)")) + + #We cannot disable the item because then it can't be selected for resume + #self.setDisabled(clientDict["lastStatus"] == "stopped") + +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.sortByColumn = 0 #by name + self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending + self.clientsTreeWidget = self.mainWindow.ui.loadedSessionClients + self.clientsTreeWidget.setIconSize(iconSize) + self.clientsTreeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.clientsTreeWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) #We only allow explicit editing. + self.clientsTreeWidget.customContextMenuRequested.connect(self.clientsContextMenu) + 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.sortByColumn(self.sortByColumn, self.sortAscending) + 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 menu""" + item = self.clientsTreeWidget.itemAt(qpoint) + if not type(item) is ClientItem: + 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 + pos = QtGui.QCursor.pos() + pos.setY(pos.y() + 5) + 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) + 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/argodejo/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) + else: + #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.sortByColumn = self.clientsTreeWidget.header().sortIndicatorSection() + self.sortDescending = self.clientsTreeWidget.header().sortIndicatorOrder() + + def _reactSignal_restoreSorting(self, *args): + self.clientsTreeWidget.sortByColumn(self.sortByColumn, self.sortDescending) + +class LauncherProgram(QtWidgets.QTreeWidgetItem): + """ + 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.4.1', + 'x-nsm-capable': 'true'} + """ + + allItems = {} # clientId : ClientItem + + def __init__(self, parentController, launcherDict:dict): + LauncherProgram.allItems[launcherDict["argodejoExec"]] = self + self.parentController = parentController + self.launcherDict = launcherDict + self.executable = launcherDict["argodejoExec"] + + 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["argodejoExec"] in programIcons: + icon = programIcons[launcherDict["argodejoExec"]] + 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.sortByColumn = 0 # by name + self.sortAscending = 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", "version", "argodejoFullPath") #basically an enum + self.headerLables = [ + QtCore.QCoreApplication.translate("Launcher", "Name"), + QtCore.QCoreApplication.translate("Launcher", "Description"), + QtCore.QCoreApplication.translate("Launcher", "Version"), + QtCore.QCoreApplication.translate("Launcher", "Path"), + ] + self.launcherWidget.setHeaderLabels(self.headerLables) + self.launcherWidget.setSortingEnabled(True) + self.launcherWidget.setAlternatingRowColors(True) + + #The actual program entries are handled by the LauncherProgram item class + self.buildPrograms() + + #Signals + self.launcherWidget.itemDoubleClicked.connect(self._reactSignal_launcherItemDoubleClicked) + + + def _adjustColumnSize(self): + self.launcherWidget.sortByColumn(self.sortByColumn, self.sortAscending) + 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 argodejoExec and argodejoFullPath, which in turn are files + guaranteed to exist in the path. + """ + self.launcherWidget.clear() + programs = api.getSystemPrograms() + + 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"]) diff --git a/qtgui/projectname.py b/qtgui/projectname.py new file mode 100644 index 0000000..c2dd82a --- /dev/null +++ b/qtgui/projectname.py @@ -0,0 +1,160 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), +more specifically its template base application. + +The Template Base 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 +import pathlib +import os + +#Third Party +from PyQt5 import QtCore, QtWidgets + +#Qt +from .designer.projectname import Ui_ProjectName +from .designer.newsession import Ui_NewSession + +#Engine +from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. +import engine.api as api + +class ProjectNameWidget(QtWidgets.QDialog): + """Ask the user for a project name. Either for renaming, new or copy. + Will have a live-detection """ + + def __init__(self, parent, startwith:str="", start=True, alsoDisable=None): + super().__init__(parent) + self.ui = Ui_ProjectName() + self.ui.setupUi(self) + + self.alsoDisable = alsoDisable #in case we are embedded + + #self.ui.labelDescription + + self.ui.labelError.setText("") + self.ui.name.setText(startwith) + self.check(startwith) + self.ui.name.textEdited.connect(self.check) #not called when text is changed programatically + + self.result = None + + self.ui.buttonBox.accepted.connect(self.process) + self.ui.buttonBox.rejected.connect(self.reject) + + if start: + self.setWindowFlag(QtCore.Qt.Popup, True) + self.setModal(True) + self.setFocus(True) + self.ui.name.setFocus(True) + self.exec_() + + def check(self, currentText): + """Called every keypress. + We do preliminary error and collision checking here, so the engine does not have to throw + an error """ + if currentText.endswith("/"): + currentText = currentText[:-1] + + path = pathlib.Path(api.sessionRoot(), currentText) + + errorMessage = "" + if currentText == "": + errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Name must not be empty.") + elif pathlib.PurePosixPath(path).match("/*"): + errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Name must be a relative path.") + elif pathlib.PurePosixPath(path).match("../*") or pathlib.PurePosixPath(path).match("*..*"): + errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Moving to parent directory not allowed.") + elif "/" in currentText and path.parent.exists() and not os.access(path.parent, os.W_OK): + errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Writing in this directory is not permitted.") + elif path.exists(): + errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Name is already in use.") + + ok = self.ui.buttonBox.button(QtWidgets.QDialogButtonBox.Ok) + if errorMessage: + if self.alsoDisable: + self.alsoDisable.setEnabled(False) + ok.setEnabled(False) + self.ui.labelError.setText(""+errorMessage+"") + else: + #print (path) + if self.alsoDisable: + self.alsoDisable.setEnabled(True) + self.ui.labelError.setText("") + ok.setEnabled(True) + + def process(self): + """Careful! Calling this eats python errors without notice. Make sure your objects exists + and your syntax is correct""" + self.result = self.ui.name.text() + self.done(True) + +class NewSessionDialog(QtWidgets.QDialog): + + def __init__(self, parent, startwith:str=""): + super().__init__(parent) + self.ui = Ui_NewSession() + self.ui.setupUi(self) + + #send our childWidget our own ok button so it can disable it for the name check + self.ok = self.ui.buttonBox.button(QtWidgets.QDialogButtonBox.Ok) + #Embed a project name widget + self.projectName = ProjectNameWidget(self, startwith, start=False, alsoDisable=self.ok) + self.projectName.ui.buttonBox.hide() + self.ui.nameGroupBox.layout().addWidget(self.projectName) + self.projectName.ui.name.returnPressed.connect(self.ok.click) #We want to accept everything when return is pressed. + + self.result = None + + nsmExecutables = api.getNsmExecutables() #type set, cached, very fast. + data = METADATA["preferredClients"]["connections"] + con = METADATA["preferredClients"]["data"] + + self.ui.checkBoxJack.setEnabled(con in nsmExecutables) + self.ui.checkBoxJack.setVisible(con in nsmExecutables) + self.ui.checkBoxData.setEnabled(data in nsmExecutables) + self.ui.checkBoxData.setVisible(data in nsmExecutables) + + self.ui.buttonBox.accepted.connect(self.process) + self.ui.buttonBox.rejected.connect(self.reject) + + self.setModal(True) + self.setWindowFlag(QtCore.Qt.Popup, True) + self.projectName.ui.name.setFocus(True) + self.exec_() + + + def process(self): + """Careful! Calling this eats python errors without notice. Make sure your objects exists + and your syntax is correct""" + assert self.ok.isEnabled() + + data = METADATA["preferredClients"]["connections"] + con = METADATA["preferredClients"]["data"] + startclients = [] + if self.ui.checkBoxJack.isChecked(): startclients.append(con) + if self.ui.checkBoxData.isChecked(): startclients.append(data) + self.result = { + "name" : self.projectName.ui.name.text(), + "startclients" : startclients, + } + self.done(True) diff --git a/qtgui/quickopensessioncontroller.py b/qtgui/quickopensessioncontroller.py new file mode 100644 index 0000000..d5b2af1 --- /dev/null +++ b/qtgui/quickopensessioncontroller.py @@ -0,0 +1,321 @@ +#! /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 ). + +The Template Base 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 + +#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 + +#Third Party +from PyQt5 import QtCore, QtGui, QtWidgets + +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', + 'argodejoFullPath': '/usr/bin/vico', + 'argodejoExec': 'vico', + 'whitelist': True} + + This is the icon that is starter and status-indicator at once. + QuickSession has only one icon per argodejoExec. + + 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 = {} #argodejoExec:StarterClientItem + + def __init__(self, parentController, desktopEntry:dict): + self.parentController = parentController + self.desktopEntry = desktopEntry + self.argodejoExec = desktopEntry["argodejoExec"] + 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 "argodejoExec" in desktopEntry, desktopEntry + if desktopEntry["argodejoExec"] in programIcons: + icon = programIcons[desktopEntry["argodejoExec"]] + 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): + """https://doc.qt.io/qt-5/qstyle.html#StandardPixmap-enum""" + standardPixmap, enabled, removeAlpha = { + "removed":(QtWidgets.QStyle.SP_TrashIcon, False, True), + "stopped":(QtWidgets.QStyle.SP_BrowserStop, False, False), + "ready": (None, True, False), + "hidden":(QtWidgets.QStyle.SP_TitleBarMaxButton, True, True), + }[status] + + if standardPixmap: + overlayPixmap = self.parentController.listWidget.style().standardPixmap(standardPixmap) + + if removeAlpha: + whiteBg = QtGui.QPixmap(overlayPixmap.size()) + whiteBg.fill(QtGui.QColor(255,255,255,255)) #red + + icon = self.parentController.mainWindow.programIcons[self.argodejoExec] + if enabled: + pixmap = icon.pixmap(QtCore.QSize(70,70)) + else: + pixmap = icon.pixmap(QtCore.QSize(70,70), QtGui.QIcon.Disabled) + + p = QtGui.QPainter(pixmap) + if removeAlpha: + p.drawPixmap(0, 0, whiteBg) + p.drawPixmap(0, 0, overlayPixmap) + p.end() + ico = QtGui.QIcon(pixmap) + else: + ico = self.parentController.mainWindow.programIcons[self.argodejoExec] + + self.setIcon(ico) + + + #Status + def ready(self): + if self.nsmClientDict["hasOptionalGUI"]: + if self.nsmClientDict["visible"]: + self._setIconOverlay("ready") + else: + self._setIconOverlay("hidden") + + self.setFlags(QtCore.Qt.ItemIsEnabled) #We can still mouseClick through parent signal when set to NoItemFlags + + def removed(self): + self.setFlags(QtCore.Qt.NoItemFlags) #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.argodejoExec) + #Development-paranoia Start + if self.nsmClientDict: + assert alreadyInSession + elif alreadyInSession: + assert self.nsmClientDict + #Development-paranoia End + + if not alreadyInSession: + api.clientAdd(self.argodejoExec) #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 + + 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. + """ + whitelist = [e for e in api.getSystemPrograms() if e["whitelist"]] + leftovers = set(StarterClientItem.allItems.keys()) #"argodejoExec" + for entry in whitelist: + exe = entry["argodejoExec"] + if exe in StarterClientItem.allItems: + leftovers.remove(entry["argodejoExec"]) + else: + #Create new. Item will be parented by Qt, so Python GC will not delete + item = StarterClientItem(parentController=self, desktopEntry=entry) + self.listWidget.addItem(item) + StarterClientItem.allItems[entry["argodejoExec"]] = item + + #Remove icons 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 + + #old: rebuild from scratch + """ + self.listWidget.clear() + StarterClientItem.allItems.clear() + whitelist = [e for e in api.getSystemPrograms() if e["whitelist"]] + for entry in whitelist: + #Create new. Item will be parented by Qt, so Python GC will not delete + item = StarterClientItem(parentController=self, desktopEntry=entry) + self.listWidget.addItem(item) + StarterClientItem.allItems[entry["argodejoExec"]] = 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. + if clientDict["dumbClient"]: + #This includes the initial loading 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.") diff --git a/qtgui/quicksessioncontroller.py b/qtgui/quicksessioncontroller.py new file mode 100644 index 0000000..22b5693 --- /dev/null +++ b/qtgui/quicksessioncontroller.py @@ -0,0 +1,108 @@ +#! /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 ). + +The Template Base 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 +import datetime + +#Third Party +from PyQt5 import QtCore, QtGui, QtWidgets + +#Engine +from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. +import engine.api as api + + + +class SessionButton(QtWidgets.QPushButton): + + def __init__(self, sessionDict): + self.sessionDict = sessionDict + super().__init__(sessionDict["nsmSessionName"]) + self.clicked.connect(self.openSession) + #width = self.fontMetrics().boundingRect(sessionDict["nsmSessionName"]).width()+10 + #self.setFixedSize(width, 40) + self.setFixedHeight(40) + font = self.font() + font.setPixelSize(font.pixelSize() * 1.2 ) + self.setFont(font) + + def openSession(self): + name = self.sessionDict["nsmSessionName"] + api.sessionOpen(name) + + +class QuickSessionController(object): + """Controls the widget, but does not subclass""" + + def __init__(self, mainWindow): + self.mainWindow = mainWindow + self.layout = mainWindow.ui.quickSessionChooser.layout() + newSessionButton = mainWindow.ui.quickNewSession + + font = newSessionButton.font() + font.setPixelSize(font.pixelSize() * 1.4) + newSessionButton.setFont(font) + + newSessionButton.setFixedHeight(40) + newSessionButton.setFocus(True) #Enter on program start creates a new session. + newSessionButton.clicked.connect(self._newTimestampSession) + api.callbacks.sessionsChanged.append(self._reactCallback_sessionsChanged) + + logger.info("Quick Session Chooser ready") + + def _clear(self): + """Clear everything but the spacer item""" + for child in self.mainWindow.ui.quickSessionChooser.children(): + if type(child) is SessionButton: + self.layout.removeWidget(child) + child.setParent(None) + del child + + def _reactCallback_sessionsChanged(self, sessionDicts:list): + """Main callback for new, added, removed, moved sessions etc.""" + logger.info("Rebuilding session buttons") + self._clear() #except the space + + spacer = self.layout.takeAt(0) + + for sessionDict in sorted(sessionDicts, key=lambda d: d["nsmSessionName"]): + self.layout.addWidget(SessionButton(sessionDict)) + + #Finally add vertical spacer + #spacerItem = QtWidgets.QSpacerItem(1, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) #int w, int h, QSizePolicy::Policy hPolicy = QSizePolicy::Minimum, QSizePolicy::Policy vPolicy = QSizePolicy::Minimum + self.layout.addItem(spacer) + + def _newTimestampSession(self): + nsmExecutables = api.getNsmExecutables() #type set, cached, very fast. + con = METADATA["preferredClients"]["data"] + data = METADATA["preferredClients"]["connections"] + startclients = [] + if con in nsmExecutables: + startclients.append(con) + if data in nsmExecutables: + startclients.append(data) + + #now = datetime.datetime.now().replace(second=0, microsecond=0).isoformat()[:-3] + now = datetime.datetime.now().replace(microsecond=0).isoformat() + name = now + api.sessionNew(name, startclients) diff --git a/qtgui/sessiontreecontroller.py b/qtgui/sessiontreecontroller.py new file mode 100644 index 0000000..f777808 --- /dev/null +++ b/qtgui/sessiontreecontroller.py @@ -0,0 +1,343 @@ +#! /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 ). + +The Template Base 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 + +#Engine +import engine.api as api + +#Qt +from .helper import sizeof_fmt +from .projectname import ProjectNameWidget +from .projectname import NewSessionDialog + +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"] = [] + """ + + 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""" + if not state == self.isDisabled(): + self.setDisabled(state) + if state: + self.setText(2, "") #number of clients + self.setText(3, "") #Symlinks + self.setText(4, "") #Size + else: + self.updateData() + + 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.sortByColumn = 0 #by name + self.sortDescending = 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.sortByColumn, self.sortDescending) + + api.callbacks.sessionsChanged.append(self._reactCallback_sessionsChanged) + api.callbacks.sessionLocked.append(self._reactCallback_sessionLocked) + api.callbacks.sessionFileChanged.append(self._reactCallback_sessionFileChanged) + + + self.treeWidget.itemDoubleClicked.connect(self._reactSignal_itemDoubleClicked) + self.treeWidget.customContextMenuRequested.connect(self.contextMenu) + 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_load_selected_session.clicked.connect(self._reactSignal_openSelected) + + + 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.sortByColumn(self.sortByColumn, self.sortDescending) + + + 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 item.isDisabled(): + listOfLabelsAndFunctions.append((QtCore.QCoreApplication.translate("SessionTree", "Force Lock Removal"), lambda: api.sessionForceLiftLock(item.sessionDict["nsmSessionName"]))) + else: + 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 _reactSignal_itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int): + if not item.isDisabled() and type(item) is SessionItem: + api.sessionOpen(item.sessionDict["nsmSessionName"]) + + def _reactSignal_openSelected(self): + item = self.treeWidget.currentItem() + 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""" + widget = ProjectNameWidget(parent=self.treeWidget, startwith=nsmSessionName+"-copy") + if widget.result: + api.sessionCopy(nsmSessionName, widget.result) + + 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.sortByColumn = self.treeWidget.header().sortIndicatorSection() + self.sortDescending = self.treeWidget.header().sortIndicatorOrder() + + def _reactSignal_restoreSorting(self, *args): + self.treeWidget.sortByColumn(self.sortByColumn, self.sortDescending) + + 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.sortByColumn(self.sortByColumn, self.sortDescending) diff --git a/qtgui/systemtray.py b/qtgui/systemtray.py new file mode 100644 index 0000000..dd20b2c --- /dev/null +++ b/qtgui/systemtray.py @@ -0,0 +1,105 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +The Non-Session-Manager by Jonathan Moore Liles : http://non.tuxfamily.org/nsm/ +With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) + +API documentation: http://non.tuxfamily.org/nsm/API.html + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), +more specifically its template base application. + +The Template Base 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") + +#Third Party +from PyQt5 import QtCore, QtGui, QtWidgets + +#Engine +import engine.api as api #This loads the engine and starts a session. + +class SystemTray(QtWidgets.QSystemTrayIcon): + + def __init__(self, mainWindow): + super().__init__(QtGui.QIcon("icon.png")) + self.mainWindow = mainWindow + self.available = self.isSystemTrayAvailable() + self.show() + #self.showMessage("Title", "Helllo!", QtWidgets.QSystemTrayIcon.Information) #title, message, icon, timeout. #has messageClicked() signal. + #Don't build the context menu here. The engine is not ready to provide us with session information. Let the callbacks do it. + + #self.messageClicked.connect(self._reactSignal_messageClicked) + self.activated.connect(self._reactSignal_activated) + + #Connect to api callbacks to rebuild context menu when session changes + api.callbacks.sessionClosed.append(self.buildContextMenu) + api.callbacks.sessionOpenReady.append(self.buildContextMenu) + api.callbacks.sessionsChanged.append(self.buildContextMenu) + + def buildContextMenu(self, *args): + """In a function for readability. + It gets rebuild everytime a session is opened or closed or the session list changed + """ + menu = QtWidgets.QMenu() + def _add(text, function): + a = QtWidgets.QAction(text, menu) + menu.addAction(a) + a.triggered.connect(function) + + nsmSessionName = api.currentSession() + + + _add(QtCore.QCoreApplication.translate("TrayIcon", "Hide/Show Argodejo"), self.mainWindow.toggleVisible) + menu.addSeparator() + + #Add other pre-defined actions + if nsmSessionName: + menu.addAction(self.mainWindow.ui.actionShow_All_Clients) + menu.addAction(self.mainWindow.ui.actionHide_All_Clients) + menu.addSeparator() + + if nsmSessionName: #We are in a loaded session + _add(QtCore.QCoreApplication.translate("TrayIcon", "Save && Close {}".format(nsmSessionName)), api.sessionClose) + _add(QtCore.QCoreApplication.translate("TrayIcon", "Abort {}".format(nsmSessionName)), api.sessionAbort) + menu.addSeparator() + _add(QtCore.QCoreApplication.translate("TrayIcon", "Save && Quit Argodejo"), self.mainWindow.closeAndQuit) + _add(QtCore.QCoreApplication.translate("TrayIcon", "Abort && Quit Argodejo"), self.mainWindow.abortAndQuit) + menu.addSeparator() + else: + for recentName in self.mainWindow.recentlyOpenedSessions.get(): + _add(f"Session: {recentName}", lambda: api.sessionOpen(recentName)) + + _add(QtCore.QCoreApplication.translate("TrayIcon", "Quit "), self.mainWindow.menuRealQuit) + + self.setContextMenu(menu) + + def _reactSignal_activated(self, qActivationReason): + """ + QtWidgets.QSystemTrayIcon.Unknown + QtWidgets.QSystemTrayIcon.Context + QtWidgets.QSystemTrayIcon.DoubleClick + QtWidgets.QSystemTrayIcon.Trigger + QtWidgets.QSystemTrayIcon.MiddleClick + """ + logger.info(f"System tray activated with reason {qActivationReason}") + if qActivationReason == QtWidgets.QSystemTrayIcon.Trigger: + self.mainWindow.toggleVisible() + + #def _reactSignal_messageClicked(self): + # """this signal is emitted when the message displayed using + # showMessage() was clicked by the user.""" + # print ("clicky") diff --git a/qtgui/waitdialog.py b/qtgui/waitdialog.py new file mode 100644 index 0000000..a2a8e23 --- /dev/null +++ b/qtgui/waitdialog.py @@ -0,0 +1,76 @@ +#! /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 ) + +The Template Base 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 + + +class WaitThread(QtCore.QThread): + def __init__(self, longRunningFunction): + self.longRunningFunction = longRunningFunction + QtCore.QThread.__init__(self) + + def __del__(self): + self.wait() + + def run(self): + self.longRunningFunction() + + +class WaitDialog(QtWidgets.QMessageBox): + """An information box that closes itself once a task is done. + Executes and shows on construction""" + + def __init__(self, mainWindow, title, text, informativeText, longRunningFunction): + #text = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit but session {} still open").format(nsmSessionName) + #informativeText = QtCore.QCoreApplication.translate("AskBeforeQuit", "Do you want to save?") + #title = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit") + + super().__init__() + self.mainWindow = mainWindow + self.setWindowFlag(QtCore.Qt.Popup, True) + self.setIcon(self.Information) + self.setText(text) + self.setWindowTitle(title) + self.setInformativeText(informativeText) + self.setStandardButtons(QtWidgets.QMessageBox.NoButton) #no buttons + + wt = WaitThread(longRunningFunction) + wt.finished.connect(self.realClose) + wt.start() + + self.exec() + + def realClose(self): + self.closeEvent = QtWidgets.QMessageBox.closeEvent + self.done(True) + + def keyPressEvent(self, event): + event.ignore() + + def closeEvent(self, event): + event.ignore() + diff --git a/tools/nsm-data.py b/tools/nsm-data.py new file mode 100755 index 0000000..2eb9cd8 --- /dev/null +++ b/tools/nsm-data.py @@ -0,0 +1,292 @@ +#! /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 ), +more specifically its template base application. + +The Template Base 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("nsm-data"); logger.info("import") + +import json +import pathlib +from time import sleep +from sys import exit as sysexit + +from nsmclient import NSMClient + +URL="https://www.laborejo.org/argodejo/nsm-data" +VERSION= 1.0 +HARD_LIMIT = 512 # no single message longer than this + +def chunkstring(string): + return [string[0+i:HARD_LIMIT+i] for i in range(0, len(string), HARD_LIMIT)] + +class DataClient(object): + """ + Keys are strings, + While nsmd OSC support int, str and float we use json exclusively. + We expect a json string and will parse it here. + + All message consist of two arguments maximum: a key and, if a create-function, a json string. + + Rule: all client-keys are send as strings, even in replies. All client-values are send as + json-string, even if originally just a string. + + Description is a multi-part message, a string. + + DataClient will register itself as Data-Storage. All other communication is done via osc. + In theory every application can read and write us (like a book!) + + We listen to OSC paths and reply to the sender, which must give its address explicitly. + /argodejo/datastorage/readall s:request-host i:request-port #Request all data + /argodejo/datastorage/read s:key s:request-host i:request-port #Request one value + + The write functions have no reply. They will print out to stdout/err but not send an error + message back. + /argodejo/datastorage/create s:key any:value #Write/Create one value + /argodejo/datastorage/update s:kecy any:value #Update a value, but only if it exists + /argodejo/datastorage/delete s:key #Remove a key/value completely + """ + + def __init__(self): + + self.data = None #Dict. created in openOrNewCallbackFunction, saved as json + self.absoluteJsonFilePath = None #pathlib.Path set by openOrNewCallbackFunction + + self._descriptionStringArray = {"identifier":None} #int:str + self._descriptionId = None + + self.nsmClient = NSMClient(prettyName = "Data-Storage", #will raise an error and exit if this example is not run from NSM. + saveCallback = self.saveCallbackFunction, + openOrNewCallback = self.openOrNewCallbackFunction, + supportsSaveStatus = True, # Change this to True if your program announces it's save status to NSM + exitProgramCallback = self.exitCallbackFunction, + broadcastCallback = None, + hideGUICallback = None, #replace with your hiding function. You need to answer in your function with nsmClient.announceGuiVisibility(False) + showGUICallback = None, #replace with your showing function. You need to answer in your function with nsmClient.announceGuiVisibility(True) + sessionIsLoadedCallback = self.sessionIsLoadedCallback, #no parametersd + loggingLevel = "error", #"info" for development or debugging, "error" for production. default is error. + ) + + #Add custom callbacks. They all receive _IncomingMessage(data) + self.nsmClient.reactions["/argodejo/datastorage/setclientoverridename"] = self.setClientOverrideName + self.nsmClient.reactions["/argodejo/datastorage/getclientoverridename"] = self.getClientOverrideName + self.nsmClient.reactions["/argodejo/datastorage/getall"] = self.getAll + self.nsmClient.reactions["/argodejo/datastorage/getdescription"] = self.getDescription + self.nsmClient.reactions["/argodejo/datastorage/setdescription"] = self.setDescription + #self.nsmClient.reactions["/argodejo/datastorage/read"] = self.reactRead + #self.nsmClient.reactions["/argodejo/datastorage/readall"] = self.reactReadAll + #self.nsmClient.reactions["/argodejo/datastorage/create"] = self.reactCreate + #self.nsmClient.reactions["/argodejo/datastorage/update"] = self.reactUpdate + #self.nsmClient.reactions["/argodejo/datastorage/delete"] = self.reactDelete + + #NsmClients only returns from init when it has a connection, and on top (for us) when session is ready. It is safe to announce now. + self.nsmClient.broadcast("/argodejo/datastorage/announce", [self.nsmClient.ourClientId, HARD_LIMIT, self.nsmClient.ourOscUrl]) + + while True: + self.nsmClient.reactToMessage() + sleep(0.05) #20fps update cycle + + def getAll(self, msg): + """A complete data dumb, intended to use once after startup. + Will split into multiple reply messages, if needed""" + senderHost, senderPort = msg.params + path = "/argodejo/datastorage/reply/getall" + encoded = json.dumps(self.data) + chunks = chunkstring(encoded) + l = len(chunks) + for index, chunk in enumerate(chunks): + listOfParameters = [index+0, l-1, chunk] + self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) + + def getDescription(self, msg)->str: + """Returns a normal string, not json""" + senderHost, senderPort = msg.params + path = "/argodejo/datastorage/reply/getdescription" + chunks = chunkstring(self.data["description"]) + l = len(chunks) + for index, chunk in enumerate(chunks): + listOfParameters = [index+0, l-1, chunk] + self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) + + def setDescription(self, msg): + """ + Answers with descriptionId and index when data was received and saved. + + The GUI needs to buffer this a bit. Don't send every char as single message. + + This is for multi-part messages + Index is 0 based, + chunk is part of a simple string, not json. + + The descriptionId:int indicates the message the chunks belong to. + If we see a new one we reset our storage. + """ + descriptionId, index, chunk, senderHost, senderPort = msg.params #str, int, str, str, int + if not self._descriptionId == descriptionId: + self._descriptionId = descriptionId + self._descriptionStringArray.clear() + self._descriptionStringArray[index] = chunk + buildString = "".join([v for k,v in sorted(self._descriptionStringArray.items())]) + self.data["description"] = buildString + self.nsmClient.announceSaveStatus(False) + + def getClientOverrideName(self, msg): + """Answers with empty string if clientId does not exist or has not data. This is a signal + for the GUI/host to use the original name!""" + clientId, senderHost, senderPort = msg.params + path = "/argodejo/datastorage/reply/getclient" + if clientId in self.data["clientOverrideNames"]: + name = self.data["clientOverrideNames"][clientId] + else: + logger.info(f"We were instructed to read client {clientId}, but it does not exist") + name = "" + listOfParameters = [clientId, json.dumps(name)] + self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) + + def setClientOverrideName(self, msg): + """We accept empty string as a name to remove the name override. + """ + clientId, jsonValue = msg.params + name = json.loads(jsonValue)[:HARD_LIMIT] + if name: + self.data["clientOverrideNames"][clientId] = name + else: + #It is possible that a client not present in our storage will send an empty string. Protect. + if clientId in self.data["clientOverrideNames"]: + del self.data["clientOverrideNames"][clientId] + self.nsmClient.announceSaveStatus(False) + + #Generic Functions. Not in use and not ready. + #Callback Reactions to OSC. They all receive _IncomingMessage(data) + def reactReadAll(self, msg): + senderHost, senderPort = msg.params + path = "/argodejo/datastorage/reply/readall" + encoded = json.dumps("") + chunks = chunkstring(encoded, 512) + l = len(chunks) + for index, chunk in enumerate(chunks): + listOfParameters = [index+0, l-1, chunk] + self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) + + def reactRead(self, msg): + key, senderHost, senderPort = msg.params + if key in self.data: + path = "/argodejo/datastorage/reply/read" + listOfParameters = [key, json.dumps(self.data[key])] + self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) + else: + logger.warning(f"We were instructed to read key {key}, but it does not exist") + + def reactCreate(self, msg): + key, jsonValue = msg.params + value = json.loads(jsonValue) + self.data[key] = value + self.nsmClient.announceSaveStatus(False) + + def reactUpdate(self, msg): + key, jsonValue = msg.params + value = json.loads(jsonValue) + if key in self.data: + self.data[key] = value + self.nsmClient.announceSaveStatus(False) + else: + logger.warning(f"We were instructed to update key {key} with value {value}, but it does not exist") + + def reactDelete(self, msg): + key = msg.params[0] + if key in self.data: + del self.data[key] + self.nsmClient.announceSaveStatus(False) + else: + logger.warning(f"We were instructed to delete key {key}, but it does not exist") + + #NSM Callbacks and File Handling + + def saveCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): + result = self.data + result["origin"] = URL + result["version"] = VERSION + jsonData = json.dumps(result, indent=2) + + try: + with open(self.absoluteJsonFilePath, "w", encoding="utf-8") as f: + f.write(jsonData) + except Exception as e: + logging.error("Will not load or save because: " + e.__repr__()) + return self.absoluteJsonFilePath + #nsmclient.py will send save status clean + + def openOrNewCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): + self.absoluteJsonFilePath = pathlib.Path(ourPath) + try: + self.data = self.openFromJson(self.absoluteJsonFilePath) + except FileNotFoundError: + self.data = None #This makes debugging output nicer. If we init Data() here all errors will be presented as follow-up error "while handling exception FileNotFoundError". + except (NotADirectoryError, PermissionError) as e: + self.data = None + logger.error("Will not load or save because: " + e.__repr__()) + + if not self.data: + self.data = {"clientOverrideNames":{}, "description":""} + logger.info("New/Open complete") + #TODO: send data + + def openFromJson(self, absoluteJsonFilePath): + with open(absoluteJsonFilePath, "r", encoding="utf-8") as f: + try: + text = f.read() + result = json.loads(text) + except Exception as error: + result = None + logger.error(error) + + if result and "version" in result and "origin" in result and result["origin"] == URL: + if result["version"] >= VERSION: + assert type(result) is dict, (result, type(result)) + logger.info("Loading file from json complete") + return result + else: + logger.error(f"""{absoluteJsonFilePath} was saved with {result["version"]} but we need {VERSION}""") + sysexit() + else: + logger.error(f"""Error. {absoluteJsonFilePath} not loaded. Not a sane argodejo/nsm-data file in json format""") + sysexit() + + + def sessionIsLoadedCallback(self): + """At one point I thought we could send our data when session is ready, so the GUI actually + has clients to rename. However, that turned out impossible or impractical. + Instead the GUI now just fails if nameOverrides that we send are not available yet and tries + again later. + + Leave that in for documentation. + """ + pass + + + + #def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments): + # print (__file__, "broadcast") + + def exitCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): + sysexit(0) + +if __name__ == '__main__': + """Creating an instance starts the client and does not return""" + DataClient() diff --git a/tools/nsmclient.py b/tools/nsmclient.py new file mode 120000 index 0000000..943d915 --- /dev/null +++ b/tools/nsmclient.py @@ -0,0 +1 @@ +/home/nils/clones/pynsm2/nsmclient.py \ No newline at end of file diff --git a/tools/nsmcmdline.py b/tools/nsmcmdline.py new file mode 100644 index 0000000..aef13e9 --- /dev/null +++ b/tools/nsmcmdline.py @@ -0,0 +1,216 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) + +The Non-Session-Manager by Jonathan Moore Liles : http://non.tuxfamily.org/nsm/ +With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) + +API documentation: http://non.tuxfamily.org/nsm/API.html + +This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), +more specifically its template base application. + +The Template Base 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 . +""" + +#Standard Library +import logging +import threading +from time import sleep +import cmd +from pprint import pprint +import sys + +from nsmservercontrol import NsmServerControl + +class NSMCmd(cmd.Cmd): + intro = "Welcome to the NSM commandline tester. Type help or ? to list commands.\nThere is no prompt, just type.\n" + prompt = "" + file = None #? + + lastClientId = None + + def _clientId(self, arg): + if arg: + NSMCmd.lastClientId = arg + return arg + elif NSMCmd.lastClientId: + return NSMCmd.lastClientId + else: + return arg + + def do_announce(self, arg): + """Announce ourselves as GUI. This is sent automatically at program start, + but nsmd only remembers the last GUI that announces. Calling announce takes back control""" + nsmServerControl.gui_announce() + + def do_ping(self, arg): + """Ping the server""" + nsmServerControl.ping() + + def do_broadcast(self, arg): + """Send a message too all clients in the current session, except ourselves""" + args = arg.split() + path = args[0] + arguments = args[1:] + nsmServerControl.broadcast(path, arguments) + + def do_listSessions(self, arg): + """Request a list of projects, or sessions, from the server.""" + nsmServerControl.list() + print ("Now call 'status'") + #This won't work because when we get back control from list the data is not here yet. + #print(nsmServerControl.internalState["sessions"]) + + def do_saveSessions(self, arg): + """Save currently open session""" + nsmServerControl.save() + + def do_closeSession(self, arg): + """Close currently open session""" + nsmServerControl.close() + + def do_abortSession(self, arg): + """Close without saving!""" + nsmServerControl.abort() + + def do_liftLockedSession(self, arg): + """Remove the .lock file from a session. Use with caution. Intended for crash recovery. + Does nothing if not locked.""" + nsmServerControl.forceLiftLock(arg) + + def do_quitServer(self, arg): + """Gracefully shut down the server, which will save. Then exit to OS.""" + #nsmServerControl.quit() #We don't need that. Called by @atexit + quit() + + def do_addClient(self, arg): + """Add one client to current session. Executable must be in $PATH""" + nsmServerControl.clientAdd(arg) + + def do_openSession(self, arg): + """Open an existing session with a name as shown by the list command""" + nsmServerControl.open(arg) + + def do_newSession(self, arg): + """Saves the current session and creates a new session.""" + nsmServerControl.new(arg) + + def do_duplicateSession(self, arg): + """Saves the current session, closes it and opens a copy of it with the given name.""" + nsmServerControl.duplicate(arg) + + def do_copySession(self, arg): + """Copy a session with our internal methods. Does not use nsm duplicate and can operate + at any time at any session, locked or not.""" + nsmSessionName, newName = arg.split() + nsmServerControl.copySession(nsmSessionName, newName) + + def do_hideClient(self, arg): + """Instruct a client to hide its GUI. Will do nothing if client does not support it""" + arg = self._clientId(arg) + nsmServerControl.clientHide(arg) + + def do_showClient(self, arg): + """Instruct a client to show its GUI again. Will do nothing if client does not support it""" + arg = self._clientId(arg) + nsmServerControl.clientShow(arg) + + def do_removeClient(self, arg): + """Remove a stopped client from a running session""" + arg = self._clientId(arg) + nsmServerControl.clientRemove(arg) + + def do_stopClient(self, arg): + """Stop a client in a running session""" + arg = self._clientId(arg) + nsmServerControl.clientStop(arg) + + def do_resumeClient(self, arg): + """Resume a previously stopped client""" + arg = self._clientId(arg) + nsmServerControl.clientResume(arg) + + def do_saveClient(self, arg): + """instruct a specific client to save""" + arg = self._clientId(arg) + nsmServerControl.clientSave(arg) + + def do_allClientsShow(self, arg): + """Call clientShow for all clients""" + nsmServerControl.allClientsShow() + + def do_allClientsHide(self, arg): + """Call clientHide for all clients""" + nsmServerControl.allClientsHide() + + def do_deleteSession(self, arg): + """Delete a session directory. This is a destructive operation without undo""" + nsmServerControl.deleteSession(arg) + + def do_renameSession(self, arg): + """Rename a non-open session. Arguments: nsmSessionName (from listSessions) newName""" + nsmSessionName, newName = arg.split() + nsmServerControl.renameSession(nsmSessionName, newName) + + #Internal, not requests for nsm + def do_status(self, arg): + """show internal status. Does not query nsm for any updates or live data. + Therefore can be out of date, e.g. after program start there are no listed + sessions. call listSessions first.""" + pprint(nsmServerControl.internalState) + + def do_loggingInfo(self, arg): + """Set logging level to very verbose""" + logging.basicConfig(level=logging.INFO) + + def do_loggingWarning(self, arg): + """Set logging level to warnings and errors""" + logging.basicConfig(level=logging.WARNING) + + def do_loggingError(self, arg): + """Set logging level to only errors""" + logging.basicConfig(level=logging.ERROR) + + #def default(self, arg): + # nsmServerControl.send(arg) + + +def run_receivingServer(): + """Run forever + http://sebastiandahlgren.se/2014/06/27/running-a-method-as-a-background-thread-in-python/ + """ + while True: + nsmServerControl.process() + sleep(0.001) + +def nothing(*args): + pass + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) #development + + try: + URL = sys.argv[1] + print ("Start with URL", sys.argv[1]) + except: + URL = None + + nsmServerControl = NsmServerControl(useCallbacks=True,sessionOpenReadyHook=nothing,sessionOpenLoadingHook=nothing,sessionClosedHook=nothing,clientStatusHook=nothing,singleInstanceActivateWindowHook=nothing,dataClientHook=nothing, parameterNsmOSCUrl=URL) + thread = threading.Thread(target=run_receivingServer, args=()) + thread.daemon = True # Daemonize thread + thread.start() # Start the execution + + NSMCmd().cmdloop() diff --git a/tools/nsmservercontrol.py b/tools/nsmservercontrol.py new file mode 120000 index 0000000..70a8f9c --- /dev/null +++ b/tools/nsmservercontrol.py @@ -0,0 +1 @@ +../engine/nsmservercontrol.py \ No newline at end of file