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
+
+
+
+
+
+
+
+
+
+
+ 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