Browse Source

initial commit

master
Nils 4 years ago
parent
commit
c98efbd6d7
  1. 8
      argodejo
  2. 328
      engine/api.py
  3. 45
      engine/config.py
  4. 230
      engine/findprograms.py
  5. 1603
      engine/nsmservercontrol.py
  6. 33
      engine/old
  7. 220
      engine/start.py
  8. 154
      engine/watcher.py
  9. BIN
      icon.png
  10. 110
      qtgui/addclientprompt.py
  11. 99
      qtgui/descriptiontextwidget.py
  12. 356
      qtgui/designer/mainwindow.py
  13. 670
      qtgui/designer/mainwindow.ui
  14. 52
      qtgui/designer/newsession.py
  15. 110
      qtgui/designer/newsession.ui
  16. 45
      qtgui/designer/projectname.py
  17. 52
      qtgui/designer/projectname.ui
  18. 75
      qtgui/eventloop.py
  19. 181
      qtgui/helper.py
  20. 448
      qtgui/mainwindow.py
  21. 482
      qtgui/opensessioncontroller.py
  22. 160
      qtgui/projectname.py
  23. 321
      qtgui/quickopensessioncontroller.py
  24. 108
      qtgui/quicksessioncontroller.py
  25. 343
      qtgui/sessiontreecontroller.py
  26. 105
      qtgui/systemtray.py
  27. 76
      qtgui/waitdialog.py
  28. 292
      tools/nsm-data.py
  29. 1
      tools/nsmclient.py
  30. 216
      tools/nsmcmdline.py
  31. 1
      tools/nsmservercontrol.py

8
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.

328
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 <http://www.gnu.org/licenses/>.
"""
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

45
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" )),
}

230
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 <male@tuxfamily.org>: 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 <http://www.gnu.org/licenses/>.
"""
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()

1603
engine/nsmservercontrol.py

File diff suppressed because it is too large

33
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

220
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 <http://www.gnu.org/licenses/>.
"""
#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

154
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 <http://www.gnu.org/licenses/>.
"""
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)

BIN
icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

110
qtgui/addclientprompt.py

@ -0,0 +1,110 @@
#! /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 <http://www.gnu.org/licenses/>.
"""
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!<br>Parameters, --switches and relative paths are not allowed.<br>Use nsm-proxy or write a starter-script instead.")
errorString = "<i>" + errorString + "</i>"
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)

99
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 <http://www.gnu.org/licenses/>.
"""
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)

356
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"))

670
qtgui/designer/mainwindow.ui

@ -0,0 +1,670 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>953</width>
<height>763</height>
</rect>
</property>
<property name="windowTitle">
<string>Argodejo</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="tabbyCat">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_quick">
<attribute name="title">
<string>Quick View</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QStackedWidget" name="quickStackedWidget">
<property name="currentIndex">
<number>1</number>
</property>
<widget class="QWidget" name="page_quickSessionChooser">
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<widget class="QPushButton" name="quickNewSession">
<property name="text">
<string>Start New Session</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="quickSessionChooser">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>98</width>
<height>28</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
<spacer name="deleteMe">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>586</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_quickSessionLoaded">
<layout class="QVBoxLayout" name="verticalLayout_8">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLineEdit" name="quickSessionNameLineEdit">
<property name="text">
<string>Session Name Goes Here</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QWidget" name="widget_2" native="true">
<layout class="QHBoxLayout" name="horizontalLayout_4">
<property name="leftMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<item>
<widget class="QPushButton" name="quickSaveOpenSession">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="quickCloseOpenSession">
<property name="text">
<string>Save and Close</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QGroupBox" name="quickSessionNotesGroupBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Session Notes</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_10">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPlainTextEdit" name="quickSessionNotesPlainTextEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QListWidget" name="quickSessionClientsListWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>6</verstretch>
</sizepolicy>
</property>
<property name="viewMode">
<enum>QListView::IconMode</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_detailed">
<attribute name="title">
<string>Full View</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QStackedWidget" name="detailedStackedWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="stack_no_session">
<layout class="QHBoxLayout" name="l_2">
<property name="spacing">
<number>6</number>
</property>
<property name="leftMargin">
<number>9</number>
</property>
<property name="topMargin">
<number>9</number>
</property>
<property name="rightMargin">
<number>9</number>
</property>
<property name="bottomMargin">
<number>9</number>
</property>
<item>
<widget class="QWidget" name="widget" native="true">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QPushButton" name="button_new_session">
<property name="text">
<string>New</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_load_selected_session">
<property name="text">
<string>Load Selected</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBoxNested">
<property name="text">
<string>Tree View</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTreeWidget" name="session_tree">
<column>
<property name="text">
<string notr="true">1</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="stack_loaded_session">
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>9</number>
</property>
<property name="topMargin">
<number>9</number>
</property>
<property name="rightMargin">
<number>9</number>
</property>
<property name="bottomMargin">
<number>9</number>
</property>
<item>
<widget class="QSplitter" name="vSplitterProgramsLog">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QSplitter" name="hSplitterLauncherClients">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>7</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="childrenCollapsible">
<bool>false</bool>
</property>
<widget class="QGroupBox" name="session_programs">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>7</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Double-click to load program</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QTreeWidget" name="loadedSessionsLauncher">
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::NoSelection</enum>
</property>
<property name="iconSize">
<size>
<width>64</width>
<height>64</height>
</size>
</property>
<property name="verticalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<property name="horizontalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<column>
<property name="text">
<string notr="true">1</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QGroupBox" name="session_loaded">
<property name="title">
<string>In current session</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QTreeWidget" name="loadedSessionClients">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>3</verstretch>
</sizepolicy>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DropOnly</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="verticalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<property name="horizontalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<column>
<property name="text">
<string notr="true">1</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QGroupBox" name="loadedSessionDescriptionGroupBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>2</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Session Notes</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPlainTextEdit" name="loadedSessionDescription">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_information">
<attribute name="title">
<string>Information</string>
</attribute>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>953</width>
<height>20</height>
</rect>
</property>
<widget class="QMenu" name="menuControl">
<property name="title">
<string>Control</string>
</property>
<addaction name="actionRebuild_Program_Database"/>
<addaction name="actionHide_in_System_Tray"/>
<addaction name="actionMenuQuit"/>
</widget>
<widget class="QMenu" name="menuSession">
<property name="title">
<string>SessionName</string>
</property>
<addaction name="actionSessionAddClient"/>
<addaction name="actionSessionSave"/>
<addaction name="actionSessionSaveAs"/>
<addaction name="actionSessionSaveAndClose"/>
<addaction name="actionSessionAbort"/>
<addaction name="separator"/>
<addaction name="actionShow_All_Clients"/>
<addaction name="actionHide_All_Clients"/>
</widget>
<widget class="QMenu" name="menuClientNameId">
<property name="title">
<string>ClientNameId</string>
</property>
<addaction name="actionClientRename"/>
<addaction name="actionClientToggleVisible"/>
<addaction name="actionClientSave_separately"/>
<addaction name="actionClientStop"/>
<addaction name="actionClientResume"/>
<addaction name="separator"/>
<addaction name="actionClientRemove"/>
</widget>
<addaction name="menuControl"/>
<addaction name="menuSession"/>
<addaction name="menuClientNameId"/>
</widget>
<action name="actionMenuQuit">
<property name="text">
<string>Quit</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+Q</string>
</property>
</action>
<action name="actionAbout">
<property name="text">
<string>About</string>
</property>
</action>
<action name="actionManual">
<property name="text">
<string>Manual</string>
</property>
</action>
<action name="actionHide_in_System_Tray">
<property name="text">
<string>Hide in System Tray</string>
</property>
<property name="shortcut">
<string>Ctrl+Q</string>
</property>
</action>
<action name="actionSessionAddClient">
<property name="text">
<string>Add Client (Prompt)</string>
</property>
<property name="shortcut">
<string>A</string>
</property>
</action>
<action name="actionSessionSave">
<property name="text">
<string>Save</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="actionSessionSaveAs">
<property name="text">
<string>Save As</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
</action>
<action name="actionSessionSaveAndClose">
<property name="text">
<string>Save and Close</string>
</property>
<property name="shortcut">
<string>Ctrl+W</string>
</property>
</action>
<action name="actionSessionAbort">
<property name="text">
<string>Abort</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+W</string>
</property>
</action>
<action name="actionClientStop">
<property name="text">
<string>Stop</string>
</property>
<property name="shortcut">
<string>Alt+O</string>
</property>
</action>
<action name="actionClientResume">
<property name="text">
<string>Resume</string>
</property>
<property name="shortcut">
<string>Alt+R</string>
</property>
</action>
<action name="actionClientSave_separately">
<property name="text">
<string>Save separately</string>
</property>
<property name="shortcut">
<string>Alt+S</string>
</property>
</action>
<action name="actionClientRemove">
<property name="text">
<string>Remove</string>
</property>
<property name="shortcut">
<string>Alt+X</string>
</property>
</action>
<action name="actionClientToggleVisible">
<property name="text">
<string>Toggle Visible</string>
</property>
<property name="shortcut">
<string>Alt+T</string>
</property>
</action>
<action name="actionShow_All_Clients">
<property name="text">
<string>Show All Clients</string>
</property>
</action>
<action name="actionHide_All_Clients">
<property name="text">
<string>Hide All Clients</string>
</property>
</action>
<action name="actionRebuild_Program_Database">
<property name="text">
<string>Rebuild Program Database</string>
</property>
</action>
<action name="actionClientRename">
<property name="text">
<string>Rename</string>
</property>
<property name="shortcut">
<string>F2</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

52
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\')"))

110
qtgui/designer/newsession.ui

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>NewSession</class>
<widget class="QDialog" name="NewSession">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>448</width>
<height>222</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="nameGroupBox">
<property name="title">
<string>New Session Name</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
</layout>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBoxJack">
<property name="text">
<string>Save JACK Connections
(adds clients 'nsm-jack')</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBoxData">
<property name="text">
<string>Client Renaming and Session Notes
(adds client 'nsm-data')</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>NewSession</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>NewSession</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

45
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"))

52
qtgui/designer/projectname.ui

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ProjectName</class>
<widget class="QWidget" name="ProjectName">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>537</width>
<height>84</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="labelError">
<property name="text">
<string>Error Message</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="labelDescription">
<property name="text">
<string>Choose a project name. Use / for subdirectories</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLineEdit" name="name">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>3</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

75
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 <http://www.gnu.org/licenses/>.
"""
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()

181
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 <http://www.gnu.org/licenses/>.
"""
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

448
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 <http://www.gnu.org/licenses/>.
"""
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)

482
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 <http://www.gnu.org/licenses/>.
"""
import logging; logger = logging.getLogger(__name__); logger.info("import")
#Standard Library
#Third Party
from PyQt5 import QtCore, QtGui, QtWidgets
#Engine
import engine.api as api
#Qt
from .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"])

160
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 <http://www.gnu.org/licenses/>.
"""
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("<i>"+errorMessage+"</i>")
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)

321
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 <http://www.gnu.org/licenses/>.
"""
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.")

108
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 <http://www.gnu.org/licenses/>.
"""
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)

343
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 <http://www.gnu.org/licenses/>.
"""
import logging; logger = logging.getLogger(__name__); logger.info("import")
#Standard Library
#Third Party
from PyQt5 import QtCore, QtGui, QtWidgets
#Engine
import engine.api as api
#Qt
from .helper import sizeof_fmt
from .projectname import ProjectNameWidget
from .projectname import NewSessionDialog
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)

105
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 <male@tuxfamily.org>: 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 <http://www.gnu.org/licenses/>.
"""
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")

76
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 <http://www.gnu.org/licenses/>.
"""
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()

292
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 <http://www.gnu.org/licenses/>.
"""
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()

1
tools/nsmclient.py

@ -0,0 +1 @@
/home/nils/clones/pynsm2/nsmclient.py

216
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 <male@tuxfamily.org>: 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 <http://www.gnu.org/licenses/>.
"""
#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()

1
tools/nsmservercontrol.py

@ -0,0 +1 @@
../engine/nsmservercontrol.py
Loading…
Cancel
Save