Browse Source

initial commit

master
Nils 5 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)