Nils
5 years ago
31 changed files with 6924 additions and 0 deletions
@ -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. |
@ -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 |
@ -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" )), |
|||
|
|||
} |
@ -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() |
File diff suppressed because it is too large
@ -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 |
|||
|
@ -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 |
@ -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) |
After Width: | Height: | Size: 1.7 KiB |
@ -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) |
@ -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) |
@ -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) |
|||