Nils
4 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) |
|||
sizePolicy.setHeightForWidth(self.loadedSessionClients.sizePolicy().hasHeightForWidth()) |
|||
self.loadedSessionClients.setSizePolicy(sizePolicy) |
|||
self.loadedSessionClients.setDragDropMode(QtWidgets.QAbstractItemView.DropOnly) |
|||
self.loadedSessionClients.setAlternatingRowColors(True) |
|||
self.loadedSessionClients.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) |
|||
self.loadedSessionClients.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) |
|||
self.loadedSessionClients.setObjectName("loadedSessionClients") |
|||
self.loadedSessionClients.headerItem().setText(0, "1") |
|||
self.verticalLayout_6.addWidget(self.loadedSessionClients) |
|||
self.loadedSessionDescriptionGroupBox = QtWidgets.QGroupBox(self.vSplitterProgramsLog) |
|||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) |
|||
sizePolicy.setHorizontalStretch(0) |
|||
sizePolicy.setVerticalStretch(2) |
|||
sizePolicy.setHeightForWidth(self.loadedSessionDescriptionGroupBox.sizePolicy().hasHeightForWidth()) |
|||
self.loadedSessionDescriptionGroupBox.setSizePolicy(sizePolicy) |
|||
self.loadedSessionDescriptionGroupBox.setObjectName("loadedSessionDescriptionGroupBox") |
|||
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.loadedSessionDescriptionGroupBox) |
|||
self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) |
|||
self.verticalLayout_2.setSpacing(0) |
|||
self.verticalLayout_2.setObjectName("verticalLayout_2") |
|||
self.loadedSessionDescription = QtWidgets.QPlainTextEdit(self.loadedSessionDescriptionGroupBox) |
|||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) |
|||
sizePolicy.setHorizontalStretch(0) |
|||
sizePolicy.setVerticalStretch(0) |
|||
sizePolicy.setHeightForWidth(self.loadedSessionDescription.sizePolicy().hasHeightForWidth()) |
|||
self.loadedSessionDescription.setSizePolicy(sizePolicy) |
|||
self.loadedSessionDescription.setObjectName("loadedSessionDescription") |
|||
self.verticalLayout_2.addWidget(self.loadedSessionDescription) |
|||
self.verticalLayout_4.addWidget(self.vSplitterProgramsLog) |
|||
self.detailedStackedWidget.addWidget(self.stack_loaded_session) |
|||
self.horizontalLayout.addWidget(self.detailedStackedWidget) |
|||
self.tabbyCat.addTab(self.tab_detailed, "") |
|||
self.tab_information = QtWidgets.QWidget() |
|||
self.tab_information.setObjectName("tab_information") |
|||
self.tabbyCat.addTab(self.tab_information, "") |
|||
self.verticalLayout.addWidget(self.tabbyCat) |
|||
MainWindow.setCentralWidget(self.centralwidget) |
|||
self.statusbar = QtWidgets.QStatusBar(MainWindow) |
|||
self.statusbar.setObjectName("statusbar") |
|||
MainWindow.setStatusBar(self.statusbar) |
|||
self.menubar = QtWidgets.QMenuBar(MainWindow) |
|||
self.menubar.setGeometry(QtCore.QRect(0, 0, 953, 20)) |
|||
self.menubar.setObjectName("menubar") |
|||
self.menuControl = QtWidgets.QMenu(self.menubar) |
|||
self.menuControl.setObjectName("menuControl") |
|||
self.menuSession = QtWidgets.QMenu(self.menubar) |
|||
self.menuSession.setObjectName("menuSession") |
|||
self.menuClientNameId = QtWidgets.QMenu(self.menubar) |
|||
self.menuClientNameId.setObjectName("menuClientNameId") |
|||
MainWindow.setMenuBar(self.menubar) |
|||
self.actionMenuQuit = QtWidgets.QAction(MainWindow) |
|||
self.actionMenuQuit.setObjectName("actionMenuQuit") |
|||
self.actionAbout = QtWidgets.QAction(MainWindow) |
|||
self.actionAbout.setObjectName("actionAbout") |
|||
self.actionManual = QtWidgets.QAction(MainWindow) |
|||
self.actionManual.setObjectName("actionManual") |
|||
self.actionHide_in_System_Tray = QtWidgets.QAction(MainWindow) |
|||
self.actionHide_in_System_Tray.setObjectName("actionHide_in_System_Tray") |
|||
self.actionSessionAddClient = QtWidgets.QAction(MainWindow) |
|||
self.actionSessionAddClient.setObjectName("actionSessionAddClient") |
|||
self.actionSessionSave = QtWidgets.QAction(MainWindow) |
|||
self.actionSessionSave.setObjectName("actionSessionSave") |
|||
self.actionSessionSaveAs = QtWidgets.QAction(MainWindow) |
|||
self.actionSessionSaveAs.setObjectName("actionSessionSaveAs") |
|||
self.actionSessionSaveAndClose = QtWidgets.QAction(MainWindow) |
|||
self.actionSessionSaveAndClose.setObjectName("actionSessionSaveAndClose") |
|||
self.actionSessionAbort = QtWidgets.QAction(MainWindow) |
|||
self.actionSessionAbort.setObjectName("actionSessionAbort") |
|||
self.actionClientStop = QtWidgets.QAction(MainWindow) |
|||
self.actionClientStop.setObjectName("actionClientStop") |
|||
self.actionClientResume = QtWidgets.QAction(MainWindow) |
|||
self.actionClientResume.setObjectName("actionClientResume") |
|||
self.actionClientSave_separately = QtWidgets.QAction(MainWindow) |
|||
self.actionClientSave_separately.setObjectName("actionClientSave_separately") |
|||
self.actionClientRemove = QtWidgets.QAction(MainWindow) |
|||
self.actionClientRemove.setObjectName("actionClientRemove") |
|||
self.actionClientToggleVisible = QtWidgets.QAction(MainWindow) |
|||
self.actionClientToggleVisible.setObjectName("actionClientToggleVisible") |
|||
self.actionShow_All_Clients = QtWidgets.QAction(MainWindow) |
|||
self.actionShow_All_Clients.setObjectName("actionShow_All_Clients") |
|||
self.actionHide_All_Clients = QtWidgets.QAction(MainWindow) |
|||
self.actionHide_All_Clients.setObjectName("actionHide_All_Clients") |
|||
self.actionRebuild_Program_Database = QtWidgets.QAction(MainWindow) |
|||
self.actionRebuild_Program_Database.setObjectName("actionRebuild_Program_Database") |
|||
self.actionClientRename = QtWidgets.QAction(MainWindow) |
|||
self.actionClientRename.setObjectName("actionClientRename") |
|||
self.menuControl.addAction(self.actionRebuild_Program_Database) |
|||
self.menuControl.addAction(self.actionHide_in_System_Tray) |
|||
self.menuControl.addAction(self.actionMenuQuit) |
|||
self.menuSession.addAction(self.actionSessionAddClient) |
|||
self.menuSession.addAction(self.actionSessionSave) |
|||
self.menuSession.addAction(self.actionSessionSaveAs) |
|||
self.menuSession.addAction(self.actionSessionSaveAndClose) |
|||
self.menuSession.addAction(self.actionSessionAbort) |
|||
self.menuSession.addSeparator() |
|||
self.menuSession.addAction(self.actionShow_All_Clients) |
|||
self.menuSession.addAction(self.actionHide_All_Clients) |
|||
self.menuClientNameId.addAction(self.actionClientRename) |
|||
self.menuClientNameId.addAction(self.actionClientToggleVisible) |
|||
self.menuClientNameId.addAction(self.actionClientSave_separately) |
|||
self.menuClientNameId.addAction(self.actionClientStop) |
|||
self.menuClientNameId.addAction(self.actionClientResume) |
|||
self.menuClientNameId.addSeparator() |
|||
self.menuClientNameId.addAction(self.actionClientRemove) |
|||
self.menubar.addAction(self.menuControl.menuAction()) |
|||
self.menubar.addAction(self.menuSession.menuAction()) |
|||
self.menubar.addAction(self.menuClientNameId.menuAction()) |
|||
|
|||
self.retranslateUi(MainWindow) |
|||
self.tabbyCat.setCurrentIndex(0) |
|||
self.quickStackedWidget.setCurrentIndex(1) |
|||
self.detailedStackedWidget.setCurrentIndex(0) |
|||
QtCore.QMetaObject.connectSlotsByName(MainWindow) |
|||
|
|||
def retranslateUi(self, MainWindow): |
|||
_translate = QtCore.QCoreApplication.translate |
|||
MainWindow.setWindowTitle(_translate("MainWindow", "Argodejo")) |
|||
self.quickNewSession.setText(_translate("MainWindow", "Start New Session")) |
|||
self.quickSessionNameLineEdit.setText(_translate("MainWindow", "Session Name Goes Here")) |
|||
self.quickSaveOpenSession.setText(_translate("MainWindow", "Save")) |
|||
self.quickCloseOpenSession.setText(_translate("MainWindow", "Save and Close")) |
|||
self.quickSessionNotesGroupBox.setTitle(_translate("MainWindow", "Session Notes")) |
|||
self.quickSessionClientsListWidget.setSortingEnabled(True) |
|||
self.tabbyCat.setTabText(self.tabbyCat.indexOf(self.tab_quick), _translate("MainWindow", "Quick View")) |
|||
self.button_new_session.setText(_translate("MainWindow", "New")) |
|||
self.button_load_selected_session.setText(_translate("MainWindow", "Load Selected")) |
|||
self.checkBoxNested.setText(_translate("MainWindow", "Tree View")) |
|||
self.session_programs.setTitle(_translate("MainWindow", "Double-click to load program")) |
|||
self.session_loaded.setTitle(_translate("MainWindow", "In current session")) |
|||
self.loadedSessionDescriptionGroupBox.setTitle(_translate("MainWindow", "Session Notes")) |
|||
self.tabbyCat.setTabText(self.tabbyCat.indexOf(self.tab_detailed), _translate("MainWindow", "Full View")) |
|||
self.tabbyCat.setTabText(self.tabbyCat.indexOf(self.tab_information), _translate("MainWindow", "Information")) |
|||
self.menuControl.setTitle(_translate("MainWindow", "Control")) |
|||
self.menuSession.setTitle(_translate("MainWindow", "SessionName")) |
|||
self.menuClientNameId.setTitle(_translate("MainWindow", "ClientNameId")) |
|||
self.actionMenuQuit.setText(_translate("MainWindow", "Quit")) |
|||
self.actionMenuQuit.setShortcut(_translate("MainWindow", "Ctrl+Shift+Q")) |
|||
self.actionAbout.setText(_translate("MainWindow", "About")) |
|||
self.actionManual.setText(_translate("MainWindow", "Manual")) |
|||
self.actionHide_in_System_Tray.setText(_translate("MainWindow", "Hide in System Tray")) |
|||
self.actionHide_in_System_Tray.setShortcut(_translate("MainWindow", "Ctrl+Q")) |
|||
self.actionSessionAddClient.setText(_translate("MainWindow", "Add Client (Prompt)")) |
|||
self.actionSessionAddClient.setShortcut(_translate("MainWindow", "A")) |
|||
self.actionSessionSave.setText(_translate("MainWindow", "Save")) |
|||
self.actionSessionSave.setShortcut(_translate("MainWindow", "Ctrl+S")) |
|||
self.actionSessionSaveAs.setText(_translate("MainWindow", "Save As")) |
|||
self.actionSessionSaveAs.setShortcut(_translate("MainWindow", "Ctrl+Shift+S")) |
|||
self.actionSessionSaveAndClose.setText(_translate("MainWindow", "Save and Close")) |
|||
self.actionSessionSaveAndClose.setShortcut(_translate("MainWindow", "Ctrl+W")) |
|||
self.actionSessionAbort.setText(_translate("MainWindow", "Abort")) |
|||
self.actionSessionAbort.setShortcut(_translate("MainWindow", "Ctrl+Shift+W")) |
|||
self.actionClientStop.setText(_translate("MainWindow", "Stop")) |
|||
self.actionClientStop.setShortcut(_translate("MainWindow", "Alt+O")) |
|||
self.actionClientResume.setText(_translate("MainWindow", "Resume")) |
|||
self.actionClientResume.setShortcut(_translate("MainWindow", "Alt+R")) |
|||
self.actionClientSave_separately.setText(_translate("MainWindow", "Save separately")) |
|||
self.actionClientSave_separately.setShortcut(_translate("MainWindow", "Alt+S")) |
|||
self.actionClientRemove.setText(_translate("MainWindow", "Remove")) |
|||
self.actionClientRemove.setShortcut(_translate("MainWindow", "Alt+X")) |
|||
self.actionClientToggleVisible.setText(_translate("MainWindow", "Toggle Visible")) |
|||
self.actionClientToggleVisible.setShortcut(_translate("MainWindow", "Alt+T")) |
|||
self.actionShow_All_Clients.setText(_translate("MainWindow", "Show All Clients")) |
|||
self.actionHide_All_Clients.setText(_translate("MainWindow", "Hide All Clients")) |
|||
self.actionRebuild_Program_Database.setText(_translate("MainWindow", "Rebuild Program Database")) |
|||
self.actionClientRename.setText(_translate("MainWindow", "Rename")) |
|||
self.actionClientRename.setShortcut(_translate("MainWindow", "F2")) |
@ -0,0 +1,670 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<ui version="4.0"> |
|||
<class>MainWindow</class> |
|||
<widget class="QMainWindow" name="MainWindow"> |
|||
<property name="geometry"> |
|||
<rect> |
|||
<x>0</x> |
|||
<y>0</y> |
|||
<width>953</width> |
|||
<height>763</height> |
|||
</rect> |
|||
</property> |
|||
<property name="windowTitle"> |
|||
<string>Argodejo</string> |
|||
</property> |
|||
<widget class="QWidget" name="centralwidget"> |
|||
<layout class="QVBoxLayout" name="verticalLayout"> |
|||
<item> |
|||
<widget class="QTabWidget" name="tabbyCat"> |
|||
<property name="currentIndex"> |
|||
<number>0</number> |
|||
</property> |
|||
<widget class="QWidget" name="tab_quick"> |
|||
<attribute name="title"> |
|||
<string>Quick View</string> |
|||
</attribute> |
|||
<layout class="QHBoxLayout" name="horizontalLayout_2"> |
|||
<item> |
|||
<widget class="QStackedWidget" name="quickStackedWidget"> |
|||
<property name="currentIndex"> |
|||
<number>1</number> |
|||
</property> |
|||
<widget class="QWidget" name="page_quickSessionChooser"> |
|||
<layout class="QVBoxLayout" name="verticalLayout_9"> |
|||
<item> |
|||
<widget class="QPushButton" name="quickNewSession"> |
|||
<property name="text"> |
|||
<string>Start New Session</string> |
|||
</property> |
|||
<property name="autoDefault"> |
|||
<bool>true</bool> |
|||
</property> |
|||
<property name="default"> |
|||
<bool>true</bool> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QScrollArea" name="scrollArea"> |
|||
<property name="widgetResizable"> |
|||
<bool>true</bool> |
|||
</property> |
|||
<widget class="QWidget" name="quickSessionChooser"> |
|||
<property name="geometry"> |
|||
<rect> |
|||
<x>0</x> |
|||
<y>0</y> |
|||
<width>98</width> |
|||
<height>28</height> |
|||
</rect> |
|||
</property> |
|||
<layout class="QVBoxLayout" name="verticalLayout_7"> |
|||
<item> |
|||
<spacer name="deleteMe"> |
|||
<property name="orientation"> |
|||
<enum>Qt::Vertical</enum> |
|||
</property> |
|||
<property name="sizeHint" stdset="0"> |
|||
<size> |
|||
<width>20</width> |
|||
<height>586</height> |
|||
</size> |
|||
</property> |
|||
</spacer> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<widget class="QWidget" name="page_quickSessionLoaded"> |
|||
<layout class="QVBoxLayout" name="verticalLayout_8"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QLineEdit" name="quickSessionNameLineEdit"> |
|||
<property name="text"> |
|||
<string>Session Name Goes Here</string> |
|||
</property> |
|||
<property name="alignment"> |
|||
<set>Qt::AlignCenter</set> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QWidget" name="widget_2" native="true"> |
|||
<layout class="QHBoxLayout" name="horizontalLayout_4"> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QPushButton" name="quickSaveOpenSession"> |
|||
<property name="text"> |
|||
<string>Save</string> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QPushButton" name="quickCloseOpenSession"> |
|||
<property name="text"> |
|||
<string>Save and Close</string> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QSplitter" name="splitter"> |
|||
<property name="orientation"> |
|||
<enum>Qt::Vertical</enum> |
|||
</property> |
|||
<widget class="QGroupBox" name="quickSessionNotesGroupBox"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>1</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
<property name="title"> |
|||
<string>Session Notes</string> |
|||
</property> |
|||
<layout class="QVBoxLayout" name="verticalLayout_10"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QPlainTextEdit" name="quickSessionNotesPlainTextEdit"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>0</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<widget class="QListWidget" name="quickSessionClientsListWidget"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>6</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
<property name="viewMode"> |
|||
<enum>QListView::IconMode</enum> |
|||
</property> |
|||
<property name="sortingEnabled"> |
|||
<bool>true</bool> |
|||
</property> |
|||
</widget> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<widget class="QWidget" name="tab_detailed"> |
|||
<attribute name="title"> |
|||
<string>Full View</string> |
|||
</attribute> |
|||
<layout class="QHBoxLayout" name="horizontalLayout"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QStackedWidget" name="detailedStackedWidget"> |
|||
<property name="currentIndex"> |
|||
<number>0</number> |
|||
</property> |
|||
<widget class="QWidget" name="stack_no_session"> |
|||
<layout class="QHBoxLayout" name="l_2"> |
|||
<property name="spacing"> |
|||
<number>6</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QWidget" name="widget" native="true"> |
|||
<layout class="QVBoxLayout" name="verticalLayout_3"> |
|||
<item> |
|||
<widget class="QPushButton" name="button_new_session"> |
|||
<property name="text"> |
|||
<string>New</string> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QPushButton" name="button_load_selected_session"> |
|||
<property name="text"> |
|||
<string>Load Selected</string> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QCheckBox" name="checkBoxNested"> |
|||
<property name="text"> |
|||
<string>Tree View</string> |
|||
</property> |
|||
<property name="checked"> |
|||
<bool>true</bool> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<spacer name="verticalSpacer"> |
|||
<property name="orientation"> |
|||
<enum>Qt::Vertical</enum> |
|||
</property> |
|||
<property name="sizeHint" stdset="0"> |
|||
<size> |
|||
<width>20</width> |
|||
<height>40</height> |
|||
</size> |
|||
</property> |
|||
</spacer> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QTreeWidget" name="session_tree"> |
|||
<column> |
|||
<property name="text"> |
|||
<string notr="true">1</string> |
|||
</property> |
|||
</column> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<widget class="QWidget" name="stack_loaded_session"> |
|||
<layout class="QVBoxLayout" name="verticalLayout_4"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>9</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QSplitter" name="vSplitterProgramsLog"> |
|||
<property name="orientation"> |
|||
<enum>Qt::Vertical</enum> |
|||
</property> |
|||
<widget class="QSplitter" name="hSplitterLauncherClients"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Expanding" vsizetype="Preferred"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>7</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
<property name="orientation"> |
|||
<enum>Qt::Horizontal</enum> |
|||
</property> |
|||
<property name="childrenCollapsible"> |
|||
<bool>false</bool> |
|||
</property> |
|||
<widget class="QGroupBox" name="session_programs"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>7</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
<property name="title"> |
|||
<string>Double-click to load program</string> |
|||
</property> |
|||
<layout class="QVBoxLayout" name="verticalLayout_5"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QTreeWidget" name="loadedSessionsLauncher"> |
|||
<property name="dragDropMode"> |
|||
<enum>QAbstractItemView::DragDrop</enum> |
|||
</property> |
|||
<property name="selectionMode"> |
|||
<enum>QAbstractItemView::NoSelection</enum> |
|||
</property> |
|||
<property name="iconSize"> |
|||
<size> |
|||
<width>64</width> |
|||
<height>64</height> |
|||
</size> |
|||
</property> |
|||
<property name="verticalScrollMode"> |
|||
<enum>QAbstractItemView::ScrollPerPixel</enum> |
|||
</property> |
|||
<property name="horizontalScrollMode"> |
|||
<enum>QAbstractItemView::ScrollPerPixel</enum> |
|||
</property> |
|||
<column> |
|||
<property name="text"> |
|||
<string notr="true">1</string> |
|||
</property> |
|||
</column> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<widget class="QGroupBox" name="session_loaded"> |
|||
<property name="title"> |
|||
<string>In current session</string> |
|||
</property> |
|||
<layout class="QVBoxLayout" name="verticalLayout_6"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QTreeWidget" name="loadedSessionClients"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>3</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
<property name="dragDropMode"> |
|||
<enum>QAbstractItemView::DropOnly</enum> |
|||
</property> |
|||
<property name="alternatingRowColors"> |
|||
<bool>true</bool> |
|||
</property> |
|||
<property name="verticalScrollMode"> |
|||
<enum>QAbstractItemView::ScrollPerPixel</enum> |
|||
</property> |
|||
<property name="horizontalScrollMode"> |
|||
<enum>QAbstractItemView::ScrollPerPixel</enum> |
|||
</property> |
|||
<column> |
|||
<property name="text"> |
|||
<string notr="true">1</string> |
|||
</property> |
|||
</column> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
</widget> |
|||
<widget class="QGroupBox" name="loadedSessionDescriptionGroupBox"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>2</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
<property name="title"> |
|||
<string>Session Notes</string> |
|||
</property> |
|||
<layout class="QVBoxLayout" name="verticalLayout_2"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<item> |
|||
<widget class="QPlainTextEdit" name="loadedSessionDescription"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding"> |
|||
<horstretch>0</horstretch> |
|||
<verstretch>0</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<widget class="QWidget" name="tab_information"> |
|||
<attribute name="title"> |
|||
<string>Information</string> |
|||
</attribute> |
|||
</widget> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<widget class="QStatusBar" name="statusbar"/> |
|||
<widget class="QMenuBar" name="menubar"> |
|||
<property name="geometry"> |
|||
<rect> |
|||
<x>0</x> |
|||
<y>0</y> |
|||
<width>953</width> |
|||
<height>20</height> |
|||
</rect> |
|||
</property> |
|||
<widget class="QMenu" name="menuControl"> |
|||
<property name="title"> |
|||
<string>Control</string> |
|||
</property> |
|||
<addaction name="actionRebuild_Program_Database"/> |
|||
<addaction name="actionHide_in_System_Tray"/> |
|||
<addaction name="actionMenuQuit"/> |
|||
</widget> |
|||
<widget class="QMenu" name="menuSession"> |
|||
<property name="title"> |
|||
<string>SessionName</string> |
|||
</property> |
|||
<addaction name="actionSessionAddClient"/> |
|||
<addaction name="actionSessionSave"/> |
|||
<addaction name="actionSessionSaveAs"/> |
|||
<addaction name="actionSessionSaveAndClose"/> |
|||
<addaction name="actionSessionAbort"/> |
|||
<addaction name="separator"/> |
|||
<addaction name="actionShow_All_Clients"/> |
|||
<addaction name="actionHide_All_Clients"/> |
|||
</widget> |
|||
<widget class="QMenu" name="menuClientNameId"> |
|||
<property name="title"> |
|||
<string>ClientNameId</string> |
|||
</property> |
|||
<addaction name="actionClientRename"/> |
|||
<addaction name="actionClientToggleVisible"/> |
|||
<addaction name="actionClientSave_separately"/> |
|||
<addaction name="actionClientStop"/> |
|||
<addaction name="actionClientResume"/> |
|||
<addaction name="separator"/> |
|||
<addaction name="actionClientRemove"/> |
|||
</widget> |
|||
<addaction name="menuControl"/> |
|||
<addaction name="menuSession"/> |
|||
<addaction name="menuClientNameId"/> |
|||
</widget> |
|||
<action name="actionMenuQuit"> |
|||
<property name="text"> |
|||
<string>Quit</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Ctrl+Shift+Q</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionAbout"> |
|||
<property name="text"> |
|||
<string>About</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionManual"> |
|||
<property name="text"> |
|||
<string>Manual</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionHide_in_System_Tray"> |
|||
<property name="text"> |
|||
<string>Hide in System Tray</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Ctrl+Q</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionSessionAddClient"> |
|||
<property name="text"> |
|||
<string>Add Client (Prompt)</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>A</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionSessionSave"> |
|||
<property name="text"> |
|||
<string>Save</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Ctrl+S</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionSessionSaveAs"> |
|||
<property name="text"> |
|||
<string>Save As</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Ctrl+Shift+S</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionSessionSaveAndClose"> |
|||
<property name="text"> |
|||
<string>Save and Close</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Ctrl+W</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionSessionAbort"> |
|||
<property name="text"> |
|||
<string>Abort</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Ctrl+Shift+W</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionClientStop"> |
|||
<property name="text"> |
|||
<string>Stop</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Alt+O</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionClientResume"> |
|||
<property name="text"> |
|||
<string>Resume</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Alt+R</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionClientSave_separately"> |
|||
<property name="text"> |
|||
<string>Save separately</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Alt+S</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionClientRemove"> |
|||
<property name="text"> |
|||
<string>Remove</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Alt+X</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionClientToggleVisible"> |
|||
<property name="text"> |
|||
<string>Toggle Visible</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>Alt+T</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionShow_All_Clients"> |
|||
<property name="text"> |
|||
<string>Show All Clients</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionHide_All_Clients"> |
|||
<property name="text"> |
|||
<string>Hide All Clients</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionRebuild_Program_Database"> |
|||
<property name="text"> |
|||
<string>Rebuild Program Database</string> |
|||
</property> |
|||
</action> |
|||
<action name="actionClientRename"> |
|||
<property name="text"> |
|||
<string>Rename</string> |
|||
</property> |
|||
<property name="shortcut"> |
|||
<string>F2</string> |
|||
</property> |
|||
</action> |
|||
</widget> |
|||
<resources/> |
|||
<connections/> |
|||
</ui> |
@ -0,0 +1,52 @@ |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
# Form implementation generated from reading ui file 'newsession.ui' |
|||
# |
|||
# Created by: PyQt5 UI code generator 5.14.1 |
|||
# |
|||
# WARNING! All changes made in this file will be lost! |
|||
|
|||
|
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
|
|||
class Ui_NewSession(object): |
|||
def setupUi(self, NewSession): |
|||
NewSession.setObjectName("NewSession") |
|||
NewSession.resize(448, 222) |
|||
self.verticalLayout = QtWidgets.QVBoxLayout(NewSession) |
|||
self.verticalLayout.setObjectName("verticalLayout") |
|||
self.nameGroupBox = QtWidgets.QGroupBox(NewSession) |
|||
self.nameGroupBox.setObjectName("nameGroupBox") |
|||
self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.nameGroupBox) |
|||
self.verticalLayout_3.setContentsMargins(0, 0, 0, 0) |
|||
self.verticalLayout_3.setSpacing(0) |
|||
self.verticalLayout_3.setObjectName("verticalLayout_3") |
|||
self.verticalLayout.addWidget(self.nameGroupBox) |
|||
self.checkBoxJack = QtWidgets.QCheckBox(NewSession) |
|||
self.checkBoxJack.setChecked(True) |
|||
self.checkBoxJack.setObjectName("checkBoxJack") |
|||
self.verticalLayout.addWidget(self.checkBoxJack) |
|||
self.checkBoxData = QtWidgets.QCheckBox(NewSession) |
|||
self.checkBoxData.setChecked(True) |
|||
self.checkBoxData.setObjectName("checkBoxData") |
|||
self.verticalLayout.addWidget(self.checkBoxData) |
|||
self.buttonBox = QtWidgets.QDialogButtonBox(NewSession) |
|||
self.buttonBox.setOrientation(QtCore.Qt.Horizontal) |
|||
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) |
|||
self.buttonBox.setObjectName("buttonBox") |
|||
self.verticalLayout.addWidget(self.buttonBox) |
|||
|
|||
self.retranslateUi(NewSession) |
|||
self.buttonBox.accepted.connect(NewSession.accept) |
|||
self.buttonBox.rejected.connect(NewSession.reject) |
|||
QtCore.QMetaObject.connectSlotsByName(NewSession) |
|||
|
|||
def retranslateUi(self, NewSession): |
|||
_translate = QtCore.QCoreApplication.translate |
|||
NewSession.setWindowTitle(_translate("NewSession", "Dialog")) |
|||
self.nameGroupBox.setTitle(_translate("NewSession", "New Session Name")) |
|||
self.checkBoxJack.setText(_translate("NewSession", "Save JACK Connections\n" |
|||
"(adds clients \'nsm-jack\')")) |
|||
self.checkBoxData.setText(_translate("NewSession", "Client Renaming and Session Notes\n" |
|||
"(adds client \'nsm-data\')")) |
@ -0,0 +1,110 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<ui version="4.0"> |
|||
<class>NewSession</class> |
|||
<widget class="QDialog" name="NewSession"> |
|||
<property name="geometry"> |
|||
<rect> |
|||
<x>0</x> |
|||
<y>0</y> |
|||
<width>448</width> |
|||
<height>222</height> |
|||
</rect> |
|||
</property> |
|||
<property name="windowTitle"> |
|||
<string>Dialog</string> |
|||
</property> |
|||
<layout class="QVBoxLayout" name="verticalLayout"> |
|||
<item> |
|||
<widget class="QGroupBox" name="nameGroupBox"> |
|||
<property name="title"> |
|||
<string>New Session Name</string> |
|||
</property> |
|||
<layout class="QVBoxLayout" name="verticalLayout_3"> |
|||
<property name="spacing"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="leftMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="topMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="rightMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
<property name="bottomMargin"> |
|||
<number>0</number> |
|||
</property> |
|||
</layout> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QCheckBox" name="checkBoxJack"> |
|||
<property name="text"> |
|||
<string>Save JACK Connections |
|||
(adds clients 'nsm-jack')</string> |
|||
</property> |
|||
<property name="checked"> |
|||
<bool>true</bool> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QCheckBox" name="checkBoxData"> |
|||
<property name="text"> |
|||
<string>Client Renaming and Session Notes |
|||
(adds client 'nsm-data')</string> |
|||
</property> |
|||
<property name="checked"> |
|||
<bool>true</bool> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item> |
|||
<widget class="QDialogButtonBox" name="buttonBox"> |
|||
<property name="orientation"> |
|||
<enum>Qt::Horizontal</enum> |
|||
</property> |
|||
<property name="standardButtons"> |
|||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<resources/> |
|||
<connections> |
|||
<connection> |
|||
<sender>buttonBox</sender> |
|||
<signal>accepted()</signal> |
|||
<receiver>NewSession</receiver> |
|||
<slot>accept()</slot> |
|||
<hints> |
|||
<hint type="sourcelabel"> |
|||
<x>248</x> |
|||
<y>254</y> |
|||
</hint> |
|||
<hint type="destinationlabel"> |
|||
<x>157</x> |
|||
<y>274</y> |
|||
</hint> |
|||
</hints> |
|||
</connection> |
|||
<connection> |
|||
<sender>buttonBox</sender> |
|||
<signal>rejected()</signal> |
|||
<receiver>NewSession</receiver> |
|||
<slot>reject()</slot> |
|||
<hints> |
|||
<hint type="sourcelabel"> |
|||
<x>316</x> |
|||
<y>260</y> |
|||
</hint> |
|||
<hint type="destinationlabel"> |
|||
<x>286</x> |
|||
<y>274</y> |
|||
</hint> |
|||
</hints> |
|||
</connection> |
|||
</connections> |
|||
</ui> |
@ -0,0 +1,45 @@ |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
# Form implementation generated from reading ui file 'projectname.ui' |
|||
# |
|||
# Created by: PyQt5 UI code generator 5.14.1 |
|||
# |
|||
# WARNING! All changes made in this file will be lost! |
|||
|
|||
|
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
|
|||
class Ui_ProjectName(object): |
|||
def setupUi(self, ProjectName): |
|||
ProjectName.setObjectName("ProjectName") |
|||
ProjectName.resize(537, 84) |
|||
self.gridLayout = QtWidgets.QGridLayout(ProjectName) |
|||
self.gridLayout.setObjectName("gridLayout") |
|||
self.buttonBox = QtWidgets.QDialogButtonBox(ProjectName) |
|||
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) |
|||
self.buttonBox.setObjectName("buttonBox") |
|||
self.gridLayout.addWidget(self.buttonBox, 1, 1, 1, 1) |
|||
self.labelError = QtWidgets.QLabel(ProjectName) |
|||
self.labelError.setObjectName("labelError") |
|||
self.gridLayout.addWidget(self.labelError, 2, 0, 1, 1) |
|||
self.labelDescription = QtWidgets.QLabel(ProjectName) |
|||
self.labelDescription.setObjectName("labelDescription") |
|||
self.gridLayout.addWidget(self.labelDescription, 0, 0, 1, 1) |
|||
self.name = QtWidgets.QLineEdit(ProjectName) |
|||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) |
|||
sizePolicy.setHorizontalStretch(3) |
|||
sizePolicy.setVerticalStretch(0) |
|||
sizePolicy.setHeightForWidth(self.name.sizePolicy().hasHeightForWidth()) |
|||
self.name.setSizePolicy(sizePolicy) |
|||
self.name.setObjectName("name") |
|||
self.gridLayout.addWidget(self.name, 1, 0, 1, 1) |
|||
|
|||
self.retranslateUi(ProjectName) |
|||
QtCore.QMetaObject.connectSlotsByName(ProjectName) |
|||
|
|||
def retranslateUi(self, ProjectName): |
|||
_translate = QtCore.QCoreApplication.translate |
|||
ProjectName.setWindowTitle(_translate("ProjectName", "Form")) |
|||
self.labelError.setText(_translate("ProjectName", "Error Message")) |
|||
self.labelDescription.setText(_translate("ProjectName", "Choose a project name. Use / for subdirectories")) |
@ -0,0 +1,52 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<ui version="4.0"> |
|||
<class>ProjectName</class> |
|||
<widget class="QWidget" name="ProjectName"> |
|||
<property name="geometry"> |
|||
<rect> |
|||
<x>0</x> |
|||
<y>0</y> |
|||
<width>537</width> |
|||
<height>84</height> |
|||
</rect> |
|||
</property> |
|||
<property name="windowTitle"> |
|||
<string>Form</string> |
|||
</property> |
|||
<layout class="QGridLayout" name="gridLayout"> |
|||
<item row="1" column="1"> |
|||
<widget class="QDialogButtonBox" name="buttonBox"> |
|||
<property name="standardButtons"> |
|||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item row="2" column="0"> |
|||
<widget class="QLabel" name="labelError"> |
|||
<property name="text"> |
|||
<string>Error Message</string> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item row="0" column="0"> |
|||
<widget class="QLabel" name="labelDescription"> |
|||
<property name="text"> |
|||
<string>Choose a project name. Use / for subdirectories</string> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
<item row="1" column="0"> |
|||
<widget class="QLineEdit" name="name"> |
|||
<property name="sizePolicy"> |
|||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed"> |
|||
<horstretch>3</horstretch> |
|||
<verstretch>0</verstretch> |
|||
</sizepolicy> |
|||
</property> |
|||
</widget> |
|||
</item> |
|||
</layout> |
|||
</widget> |
|||
<resources/> |
|||
<connections/> |
|||
</ui> |
@ -0,0 +1,75 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), |
|||
more specifically its template base application. |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
from PyQt5 import QtCore |
|||
|
|||
class EventLoop(object): |
|||
def __init__(self): |
|||
"""The loop for all things GUI and controlling the GUI (e.g. by a control midi in port) |
|||
|
|||
By default use fastConnect. |
|||
|
|||
0 ms means "if there is time". 10ms-20ms is smooth. 100ms is still ok. |
|||
Influences everything. Control Midi In Latency, playback cursor scrolling smoothnes etc. |
|||
|
|||
But not realtime. This is not the realtime loop. Converting midi into instrument sounds |
|||
or playing back sequenced midi data is not handled by this loop at all. |
|||
|
|||
Creating a non-qt class for the loop is an abstraction layer that enables the engine to |
|||
work without modification for non-gui situations. In this case it will use its own loop, |
|||
like python async etc. |
|||
|
|||
A qt event loop needs the qt-app started. Otherwise it will not run. |
|||
We init the event loop outside of main but call start from the mainWindow. |
|||
""" |
|||
|
|||
self.fastLoop = QtCore.QTimer() |
|||
self.slowLoop = QtCore.QTimer() |
|||
|
|||
def fastConnect(self, function): |
|||
self.fastLoop.timeout.connect(function) |
|||
|
|||
def slowConnect(self, function): |
|||
self.slowLoop.timeout.connect(function) |
|||
|
|||
def fastDisconnect(self, function): |
|||
"""The function must be the exact instance that was registered""" |
|||
self.fastLoop.timeout.disconnect(function) |
|||
|
|||
def slowDisconnect(self, function): |
|||
"""The function must be the exact instance that was registered""" |
|||
self.slowLoop.timeout.disconnect(function) |
|||
|
|||
def start(self): |
|||
"""The event loop MUST be started after the Qt Application instance creation""" |
|||
logger.info("Starting fast qt event loop") |
|||
self.fastLoop.start(20) |
|||
logger.info("Starting slow qt event loop") |
|||
self.slowLoop.start(200) |
|||
|
|||
def stop(self): |
|||
logger.info("Stopping fast qt event loop") |
|||
self.fastLoop.stop() |
|||
logger.info("Stopping slow qt event loop") |
|||
self.slowLoop.stop() |
@ -0,0 +1,181 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2017, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of Laborejo ( https://www.laborejo.org ) |
|||
|
|||
Laborejo is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
from hashlib import md5 |
|||
|
|||
def iconFromString(st, size=128): |
|||
px = QtGui.QPixmap(size,size) |
|||
color = stringToColor(st) |
|||
px.fill(color) |
|||
return QtGui.QIcon(px) |
|||
|
|||
|
|||
def sizeof_fmt(num, suffix='B'): |
|||
"""https://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size""" |
|||
for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']: |
|||
if abs(num) < 1024.0: |
|||
return "%3.1f%s%s" % (num, unit, suffix) |
|||
num /= 1024.0 |
|||
return "%.1f%s%s" % (num, 'Yi', suffix) |
|||
|
|||
def stringToColor(st): |
|||
"""Convert a string to QColor. Same string, same color |
|||
Is used for group coloring""" |
|||
if st: |
|||
c = md5(st.encode()).hexdigest() |
|||
return QtGui.QColor(int(c[0:9],16) % 255, int(c[10:19],16) % 255, int(c[20:29],16)% 255, 255) |
|||
else: |
|||
return QtGui.QColor(255,255,255,255) #Return White |
|||
|
|||
def invertColor(qcolor): |
|||
r = 255 - qcolor.red() |
|||
g = 255 - qcolor.green() |
|||
b = 255 - qcolor.blue() |
|||
return QtGui.QColor(r, g, b, qcolor.alpha()) |
|||
|
|||
def removeInstancesFromScene(qtGraphicsClass): |
|||
""""Remove all instances of a qt class that implements .instances=[] from the QGraphicsScene. |
|||
Don't use for items or anything in the notation view. This is used by the likes of the conductor |
|||
only since they exist only once and gets redrawn completely each time.""" |
|||
for instance in qtGraphicsClass.instances: |
|||
instance.setParentItem(None) |
|||
instance.scene().removeWhenIdle(instance) |
|||
qtGraphicsClass.instances = [] |
|||
|
|||
def callContextMenu(listOfLabelsAndFunctions): |
|||
menu = QtWidgets.QMenu() |
|||
|
|||
for text, function in listOfLabelsAndFunctions: |
|||
if text == "separator": |
|||
menu.addSeparator() |
|||
else: |
|||
a = QtWidgets.QAction(text, menu) |
|||
menu.addAction(a) |
|||
a.triggered.connect(function) |
|||
|
|||
pos = QtGui.QCursor.pos() |
|||
pos.setY(pos.y() + 5) |
|||
menu.exec_(pos) |
|||
|
|||
def stretchLine(qGraphicsLineItem, factor): |
|||
line = qGraphicsLineItem.line() |
|||
line.setLength(line.length() * factor) |
|||
qGraphicsLineItem.setLine(line) |
|||
|
|||
def stretchRect(qGraphicsRectItem, factor): |
|||
r = qGraphicsRectItem.rect() |
|||
r.setRight(r.right() * factor) |
|||
qGraphicsRectItem.setRect(r) |
|||
|
|||
class QHLine(QtWidgets.QFrame): |
|||
def __init__(self): |
|||
super().__init__() |
|||
self.setFrameShape(QtWidgets.QFrame.HLine) |
|||
self.setFrameShadow(QtWidgets.QFrame.Sunken) |
|||
|
|||
def setPaletteAndFont(qtApp): |
|||
"""Set our programs color |
|||
This is in its own function because it is a annoying to scroll by that in init. |
|||
http://doc.qt.io/qt-5/qpalette.html""" |
|||
fPalBlue = QtGui.QPalette() |
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Window, QtGui.QColor(32, 35, 39)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Window, QtGui.QColor(37, 40, 45)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Window, QtGui.QColor(37, 40, 45)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.WindowText, QtGui.QColor(89, 95, 104)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.WindowText, QtGui.QColor(223, 237, 255)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.WindowText, QtGui.QColor(223, 237, 255)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Base, QtGui.QColor(48, 53, 60)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Base, QtGui.QColor(55, 61, 69)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Base, QtGui.QColor(55, 61, 69)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.AlternateBase, QtGui.QColor(60, 64, 67)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.AlternateBase, QtGui.QColor(69, 73, 77)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.AlternateBase, QtGui.QColor(69, 73, 77)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ToolTipBase, QtGui.QColor(182, 193, 208)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.ToolTipBase, QtGui.QColor(182, 193, 208)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.ToolTipBase, QtGui.QColor(182, 193, 208)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ToolTipText, QtGui.QColor(42, 44, 48)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.ToolTipText, QtGui.QColor(42, 44, 48)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.ToolTipText, QtGui.QColor(42, 44, 48)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Text, QtGui.QColor(96, 103, 113)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Text, QtGui.QColor(210, 222, 240)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Text, QtGui.QColor(210, 222, 240)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Button, QtGui.QColor(51, 55, 62)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Button, QtGui.QColor(59, 63, 71)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Button, QtGui.QColor(59, 63, 71)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ButtonText, QtGui.QColor(98, 104, 114)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.ButtonText, QtGui.QColor(210, 222, 240)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.ButtonText, QtGui.QColor(210, 222, 240)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.BrightText, QtGui.QColor(255, 255, 255)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.BrightText, QtGui.QColor(255, 255, 255)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.BrightText, QtGui.QColor(255, 255, 255)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Light, QtGui.QColor(59, 64, 72)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Light, QtGui.QColor(63, 68, 76)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Light, QtGui.QColor(63, 68, 76)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Midlight, QtGui.QColor(48, 52, 59)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Midlight, QtGui.QColor(51, 56, 63)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Midlight, QtGui.QColor(51, 56, 63)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Dark, QtGui.QColor(18, 19, 22)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Dark, QtGui.QColor(20, 22, 25)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Dark, QtGui.QColor(20, 22, 25)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Mid, QtGui.QColor(28, 30, 34)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Mid, QtGui.QColor(32, 35, 39)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Mid, QtGui.QColor(32, 35, 39)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Shadow, QtGui.QColor(13, 14, 16)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Shadow, QtGui.QColor(15, 16, 18)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Shadow, QtGui.QColor(15, 16, 18)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Highlight, QtGui.QColor(32, 35, 39)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Highlight, QtGui.QColor(14, 14, 17)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Highlight, QtGui.QColor(27, 28, 33)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.HighlightedText, QtGui.QColor(89, 95, 104)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.HighlightedText, QtGui.QColor(217, 234, 253)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.HighlightedText, QtGui.QColor(223, 237, 255)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Link, QtGui.QColor(79, 100, 118)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.Link, QtGui.QColor(156, 212, 255)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Link, QtGui.QColor(156, 212, 255)) |
|||
|
|||
fPalBlue.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.LinkVisited, QtGui.QColor(51, 74, 118)) |
|||
fPalBlue.setColor(QtGui.QPalette.Active, QtGui.QPalette.LinkVisited, QtGui.QColor(64, 128, 255)) |
|||
fPalBlue.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.LinkVisited, QtGui.QColor(64, 128, 255)) |
|||
|
|||
qtApp.setPalette(fPalBlue) |
|||
|
|||
font = QtGui.QFont("DejaVu Sans", 10) |
|||
font.setPixelSize(12) |
|||
qtApp.setFont(font) |
|||
return fPalBlue |
@ -0,0 +1,448 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ). |
|||
|
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
#Standard Library |
|||
from sys import argv as sysargv |
|||
from sys import exit as sysexit |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
#Engine |
|||
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. |
|||
from engine.start import PATHS |
|||
import engine.api as api #This loads the engine and starts a session. |
|||
|
|||
#Qt |
|||
from .systemtray import SystemTray |
|||
from .eventloop import EventLoop |
|||
from .designer.mainwindow import Ui_MainWindow |
|||
from .helper import setPaletteAndFont |
|||
from .helper import iconFromString |
|||
from .sessiontreecontroller import SessionTreeController |
|||
from .opensessioncontroller import OpenSessionController |
|||
from .quicksessioncontroller import QuickSessionController |
|||
from .quickopensessioncontroller import QuickOpenSessionController |
|||
from .projectname import ProjectNameWidget |
|||
from .addclientprompt import askForExecutable, updateWordlist |
|||
from .waitdialog import WaitDialog |
|||
|
|||
|
|||
api.eventLoop = EventLoop() |
|||
|
|||
#Construct QAppliction before constantsAndCOnfigs, which has the fontDB |
|||
QtGui.QGuiApplication.setDesktopSettingsAware(False) #We need our own font so the user interface stays predictable |
|||
QtGui.QGuiApplication.setDesktopFileName(PATHS["desktopfile"]) |
|||
qtApp = QtWidgets.QApplication(sysargv) |
|||
|
|||
#Setup the translator before classes are set up. Otherwise we can't use non-template translation. |
|||
#to test use LANGUAGE=de_DE.UTF-8 . not LANG= |
|||
language = QtCore.QLocale().languageToString(QtCore.QLocale().language()) |
|||
logger.info("{}: Language set to {}".format(METADATA["name"], language)) |
|||
if language in METADATA["supportedLanguages"]: |
|||
translator = QtCore.QTranslator() |
|||
translator.load(METADATA["supportedLanguages"][language], ":/template/translations/") #colon to make it a resource URL |
|||
qtApp.installTranslator(translator) |
|||
|
|||
else: |
|||
"""silently fall back to English by doing nothing""" |
|||
|
|||
|
|||
def nothing(*args): |
|||
pass |
|||
|
|||
class RecentlyOpenedSessions(object): |
|||
"""Class to make it easier handle recently opened session with qt settings, type conversions |
|||
limiting the size of the list and uniqueness""" |
|||
|
|||
def __init__(self): |
|||
self.data = [] |
|||
|
|||
def load(self, dataFromQtSettings): |
|||
"""Handle qt settings load, working around everything it has""" |
|||
if dataFromQtSettings: |
|||
for name in dataFromQtSettings: |
|||
self.add(name) |
|||
|
|||
def add(self, nsmSessionName:str): |
|||
if nsmSessionName in self.data: |
|||
return |
|||
|
|||
self.data.append(nsmSessionName) |
|||
if len(self.data) > 3: |
|||
self.data.pop(0) |
|||
assert len(self.data) <= 3, len(self.data) |
|||
|
|||
|
|||
def get(self): |
|||
sessionList = api.sessionList() |
|||
self.data = [n for n in self.data if n in sessionList] |
|||
return self.data |
|||
|
|||
|
|||
class MainWindow(QtWidgets.QMainWindow): |
|||
def __init__(self): |
|||
super().__init__() |
|||
self.qtApp = qtApp |
|||
self.qtApp.setWindowIcon(QtGui.QIcon("icon.png")) #non-template part of the program |
|||
self.qtApp.setApplicationName(f"{METADATA['name']}") |
|||
self.qtApp.setApplicationDisplayName(f"{METADATA['name']}") |
|||
logger.info("Init MainWindow") |
|||
|
|||
#Set up the user interface from Designer and other widgets |
|||
self.ui = Ui_MainWindow() |
|||
self.ui.setupUi(self) |
|||
self.fPalBlue = setPaletteAndFont(self.qtApp) |
|||
|
|||
self.sessionController = SessionController(mainWindow=self) |
|||
self.systemTray = SystemTray(mainWindow=self) |
|||
self.connectMenu() |
|||
|
|||
self.recentlyOpenedSessions = RecentlyOpenedSessions() |
|||
self.programIcons = {} #executableName:QIcon. Filled by self.updateProgramDatabase |
|||
|
|||
#Menu |
|||
self.ui.actionRebuild_Program_Database.triggered.connect(self.updateProgramDatabase) |
|||
|
|||
#Api Callbacks |
|||
api.callbacks.sessionClosed.append(self.reactCallback_sessionClosed) |
|||
api.callbacks.sessionOpenReady.append(self.reactCallback_sessionOpen) |
|||
api.callbacks.singleInstanceActivateWindow.append(self.activateAndRaise) |
|||
|
|||
#Starting the engine sends initial GUI data. Every window and widget must be ready to receive callbacks here |
|||
api.eventLoop.start() |
|||
api.startEngine() |
|||
logger.info("Show MainWindow") |
|||
self.restoreWindowSettings() #includes show/hide |
|||
|
|||
|
|||
#Handle the application data cache. If not present instruct the engine to build one. |
|||
#This is also needed by the prompt in sessionController |
|||
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) |
|||
if settings.contains("programDatabase"): |
|||
listOfDicts = settings.value("programDatabase") |
|||
api.setSystemsPrograms(listOfDicts) |
|||
logger.info("Restored program database from qt cache to engine") |
|||
self._updateGUIWithCachedPrograms() |
|||
else: #First or fresh start |
|||
#A single shot timer with 0 durations is executed only after the app starts, thus the main window is ready. |
|||
QtCore.QTimer.singleShot(0, self.updateProgramDatabase) #includes self._updateGUIWithCachedPrograms() |
|||
|
|||
if not self.isVisible(): |
|||
text = QtCore.QCoreApplication.translate("mainWindow", "Argodejo ready") |
|||
self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal. |
|||
|
|||
qtApp.exec_() |
|||
#No code after exec_ |
|||
|
|||
def hideEvent(self, event): |
|||
if self.systemTray.available: |
|||
super().hideEvent(event) |
|||
else: |
|||
event.ignore() |
|||
|
|||
def activateAndRaise(self): |
|||
self.toggleVisible(force=True) |
|||
getattr(self, "raise")() #raise is python syntax. Can't use that directly |
|||
self.activateWindow() |
|||
text = QtCore.QCoreApplication.translate("activateAndRaise", "Another GUI tried to launch.") |
|||
self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal. |
|||
|
|||
def _updateGUIWithCachedPrograms(self): |
|||
logger.info("Updating entire program with cached program lists") |
|||
updateWordlist() #addclientprompt.py |
|||
self._updateIcons() |
|||
self.sessionController.openSessionController.launcherTable.buildPrograms() |
|||
self.sessionController.quickOpenSessionController.buildCleanStarterClients(nsmSessionExportDict={}) #wants a dict parameter for callback compatibility, but doesn't use it |
|||
|
|||
def _updateIcons(self): |
|||
logger.info("Creating icon database") |
|||
programs = api.getSystemPrograms() |
|||
self.programIcons.clear() |
|||
for entry in programs: |
|||
exe = entry["argodejoExec"] |
|||
if "icon" in entry: |
|||
icon = QtGui.QIcon.fromTheme(entry["icon"]) |
|||
else: |
|||
icon = QtGui.QIcon.fromTheme(exe) |
|||
if icon.isNull(): |
|||
icon = iconFromString(exe) |
|||
self.programIcons[exe] = icon |
|||
|
|||
def updateProgramDatabase(self): |
|||
"""Display a progress-dialog that waits for the database to be build. |
|||
Automatically called on first start or when instructed by the user""" |
|||
text = QtCore.QCoreApplication.translate("updateProgramDatabase", "Updating Program Database") |
|||
informativeText = QtCore.QCoreApplication.translate("updateProgramDatabase", "Thank you for your patience.") |
|||
title = QtCore.QCoreApplication.translate("updateProgramDatabase", "Updating") |
|||
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) |
|||
settings.remove("programDatabase") |
|||
|
|||
WaitDialog(self, title, text, informativeText, api.buildSystemPrograms) |
|||
settings.setValue("programDatabase", api.getSystemPrograms()) |
|||
self._updateGUIWithCachedPrograms() |
|||
|
|||
def reactCallback_sessionClosed(self): |
|||
self.setWindowTitle("") |
|||
|
|||
def reactCallback_sessionOpen(self, nsmSessionExportDict): |
|||
self.setWindowTitle(nsmSessionExportDict["nsmSessionName"]) |
|||
self.recentlyOpenedSessions.add(nsmSessionExportDict["nsmSessionName"]) |
|||
|
|||
def toggleVisible(self, force:bool=None): |
|||
if force: |
|||
newState = force |
|||
else: |
|||
newState = not self.isVisible() |
|||
|
|||
if newState: |
|||
logger.info("Show") |
|||
self.restoreWindowSettings() |
|||
self.show() |
|||
else: |
|||
logger.info("Hide") |
|||
self.storeWindowSettings() |
|||
self.hide() |
|||
#self.systemTray.buildContextMenu() #Don't. This crashes Qt through some delayed execution of who knows what. Workaround: tray context menu now say "Show/Hide" and not only the actual state. |
|||
|
|||
def _askBeforeQuit(self, nsmSessionName): |
|||
"""If you quit while in a session ask what to do. |
|||
The TrayIcon context menu uses different functions and directly acts, without a question""" |
|||
text = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit but session {} still open").format(nsmSessionName) |
|||
informativeText = QtCore.QCoreApplication.translate("AskBeforeQuit", "Do you want to save?") |
|||
title = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit") |
|||
|
|||
box = QtWidgets.QMessageBox() |
|||
box.setWindowFlag(QtCore.Qt.Popup, True) |
|||
box.setIcon(box.Warning) |
|||
box.setText(text) |
|||
box.setWindowTitle(title) |
|||
box.setInformativeText(informativeText) |
|||
|
|||
stay = box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Don't Quit"), box.RejectRole) #0 |
|||
box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Save"), box.YesRole) #1 |
|||
box.addButton(QtCore.QCoreApplication.translate("AskBeforeQuit", "Discard Changes"), box.DestructiveRole) #2 |
|||
box.setDefaultButton(stay) |
|||
ret = box.exec() #Return values are NOT the button roles. |
|||
|
|||
if ret == 2: |
|||
logger.info("Quit: Don't save.") |
|||
api.sessionAbort(blocking=True) |
|||
return True |
|||
elif ret == 1: |
|||
logger.info("Quit: Close and Save. Waiting for clients to close.") |
|||
api.sessionClose(blocking=True) |
|||
return True |
|||
else: #Escape, window close through WM etc. |
|||
logger.info("Quit: Changed your mind, stay in session.") |
|||
return False |
|||
|
|||
def abortAndQuit(self): |
|||
"""For the context menu. A bit |
|||
A bit redundant, but that is ok :)""" |
|||
api.sessionAbort(blocking=True) |
|||
self._callSysExit() |
|||
|
|||
def closeAndQuit(self): |
|||
api.sessionClose(blocking=True) |
|||
self._callSysExit() |
|||
|
|||
def _callSysExit(self): |
|||
"""The process of quitting |
|||
After sysexit the atexit handler gets called. |
|||
That closes nsmd, if we started ourselves. |
|||
""" |
|||
self.storeWindowSettings() |
|||
sysexit(0) #directly afterwards @atexit is handled, but this function does not return. |
|||
logging.error("Code executed after sysexit. This message should not have been visible.") |
|||
|
|||
def menuRealQuit(self): |
|||
"""Called by the menu. |
|||
The TrayIcon provides another method of quitting that does not call this function, |
|||
but it will call _actualQuit. |
|||
""" |
|||
if api.currentSession(): |
|||
result = self._askBeforeQuit(api.currentSession()) |
|||
else: |
|||
result = True |
|||
|
|||
if result: |
|||
self.storeWindowSettings() |
|||
self._callSysExit() |
|||
|
|||
|
|||
def closeEvent(self, event): |
|||
"""Window manager close. |
|||
Ignore. We use it to send the GUI into hiding.""" |
|||
event.ignore() |
|||
self.toggleVisible(force=False) |
|||
|
|||
def connectMenu(self): |
|||
#Control |
|||
self.ui.actionHide_in_System_Tray.triggered.connect(lambda: self.toggleVisible(force=False)) |
|||
self.ui.actionMenuQuit.triggered.connect(self.menuRealQuit) |
|||
|
|||
def storeWindowSettings(self): |
|||
"""Window state is not saved in the real save file. That would lead to portability problems |
|||
between computers, like different screens and resolutions. |
|||
|
|||
For convenience that means we just use the damned qt settings and save wherever qt wants. |
|||
|
|||
bottom line: get a tiling window manager. |
|||
""" |
|||
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) |
|||
settings.setValue("geometry", self.saveGeometry()) |
|||
settings.setValue("windowState", self.saveState()) |
|||
settings.setValue("visible", self.isVisible()) |
|||
settings.setValue("recentlyOpenedSessions", self.recentlyOpenedSessions.get()) |
|||
settings.setValue("tab", self.ui.tabbyCat.currentIndex()) |
|||
|
|||
def restoreWindowSettings(self): |
|||
"""opposite of storeWindowSettings. Read there.""" |
|||
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"]) |
|||
|
|||
actions = { |
|||
"geometry":self.restoreGeometry, |
|||
"windowState":self.restoreState, |
|||
"recentlyOpenedSessions":self.recentlyOpenedSessions.load, |
|||
"visible":self.recentlyOpenedSessions.load, |
|||
"tab": lambda i: self.ui.tabbyCat.setCurrentIndex(int(i)), |
|||
} |
|||
|
|||
for key in settings.allKeys(): |
|||
if key in actions: #if not it doesn't matter. this is all uncritical. |
|||
actions[key](settings.value(key)) |
|||
|
|||
if self.systemTray.available and settings.contains("visible") and settings.value("visible") == "false": |
|||
self.setVisible(False) |
|||
else: |
|||
self.setVisible(True) #This is also the default state if there is no config |
|||
|
|||
|
|||
class SessionController(object): |
|||
"""Controls the StackWidget that contains the Session Tree, Open Session/Client and their |
|||
quick and easy variants. |
|||
Can be controlled from up and down the hierarchy. |
|||
|
|||
While all tabs are open at the same time for simplicity we hide the menus when in quick-view. |
|||
""" |
|||
|
|||
def __init__(self, mainWindow): |
|||
super().__init__() |
|||
self.mainWindow = mainWindow |
|||
self.ui = self.mainWindow.ui |
|||
self.sessionTreeController = SessionTreeController(mainWindow=mainWindow) |
|||
self.openSessionController = OpenSessionController(mainWindow=mainWindow) |
|||
|
|||
self.quickSessionController = QuickSessionController(mainWindow=mainWindow) |
|||
self.quickOpenSessionController = QuickOpenSessionController(mainWindow=mainWindow) |
|||
self._connectMenu() |
|||
|
|||
#Callbacks |
|||
#api.callbacks.sessionOpenReady.append(lambda nsmSessionExportDict: self._switch("open")) #When loading ist done. This takes a while when non-nsm clients are in the session |
|||
api.callbacks.sessionClosed.append(lambda: self._setMenuEnabled(None)) |
|||
api.callbacks.sessionClosed.append(lambda: self._switch("choose")) #The rest is handled by the widget itself. It keeps itself updated, no matter if visible or not. |
|||
api.callbacks.sessionOpenReady.append(lambda nsmSessionExportDict: self._setMenuEnabled(nsmSessionExportDict)) |
|||
api.callbacks.sessionOpenLoading.append(lambda nsmSessionExportDict: self._switch("open")) |
|||
|
|||
#Convenience Signals to directly disable the client messages on gui instruction. |
|||
#This is purely for speed and preventing the user from sending a signal while the session is shutting down |
|||
self.mainWindow.ui.actionSessionAbort.triggered.connect(lambda: self._setMenuEnabled(None)) |
|||
self.mainWindow.ui.actionSessionSaveAndClose.triggered.connect(lambda: self._setMenuEnabled(None)) |
|||
|
|||
#GUI signals |
|||
self.mainWindow.ui.tabbyCat.currentChanged.connect(self._activeTabChanged) |
|||
|
|||
self._activeTabChanged(self.mainWindow.ui.tabbyCat.currentIndex()) |
|||
|
|||
def _activeTabChanged(self, index:int): |
|||
"""index 0 quick, 1 detailed, 2 info""" |
|||
if index == 1: #detailed |
|||
state = bool(api.currentSession()) |
|||
else: #quick and information and future tabs |
|||
state = False |
|||
|
|||
self.ui.menuClientNameId.menuAction().setVisible(state) #already deactivated |
|||
self.ui.menuSession.menuAction().setVisible(state) #already deactivated |
|||
|
|||
#It is not enough to disable the menu itself. Shortcuts will still work. We need the children! |
|||
for action in self.ui.menuSession.actions(): |
|||
action.setEnabled(state) |
|||
|
|||
if state and not self.openSessionController.clientTabe.clientsTreeWidget.currentItem(): |
|||
state = False #we wanted to activate, but there is no client selected. |
|||
for action in self.ui.menuClientNameId.actions(): |
|||
action.setEnabled(state) |
|||
|
|||
def _connectMenu(self): |
|||
#Session |
|||
#Only Active when a session is currently available |
|||
self.ui.actionSessionSave.triggered.connect(api.sessionSave) |
|||
self.ui.actionSessionAbort.triggered.connect(api.sessionAbort) |
|||
self.ui.actionSessionSaveAs.triggered.connect(self._reactMenu_SaveAs) #NSM "Duplicate" |
|||
self.ui.actionSessionSaveAndClose.triggered.connect(api.sessionClose) |
|||
self.ui.actionShow_All_Clients.triggered.connect(api.clientShowAll) |
|||
self.ui.actionHide_All_Clients.triggered.connect(api.clientHideAll) |
|||
self.ui.actionSessionAddClient.triggered.connect(lambda: askForExecutable(self.mainWindow)) #Prompt version |
|||
|
|||
def _reactMenu_SaveAs(self): |
|||
"""Only when a session is open. |
|||
We could either check the session controller or the simple one for the name.""" |
|||
currentName = api.currentSession() |
|||
assert currentName |
|||
widget = ProjectNameWidget(parent=self.mainWindow, startwith=currentName+"-new") |
|||
if widget.result: |
|||
api.sessionSaveAs(widget.result) |
|||
|
|||
def _setMenuEnabled(self, nsmSessionExportDictOrNone): |
|||
"""We receive the sessionDict or None""" |
|||
state = bool(nsmSessionExportDictOrNone) |
|||
if state: |
|||
self.ui.menuSession.setTitle(nsmSessionExportDictOrNone["nsmSessionName"]) |
|||
self.ui.menuSession.menuAction().setVisible(True) |
|||
self.ui.menuClientNameId.menuAction().setVisible(True) #session controller might disable that |
|||
else: |
|||
self.ui.menuSession.setTitle("Session") |
|||
self.ui.menuSession.menuAction().setVisible(False) |
|||
self.ui.menuClientNameId.menuAction().setVisible(False) #already deactivated |
|||
|
|||
#self.ui.menuSession.setEnabled(state) #It is not enough to disable the menu itself. Shortcuts will still work. |
|||
for action in self.ui.menuSession.actions(): |
|||
action.setEnabled(state) |
|||
|
|||
#Maybe the tab state overrules everything |
|||
self._activeTabChanged(self.mainWindow.ui.tabbyCat.currentIndex()) |
|||
|
|||
def _switch(self, page:str): |
|||
"""Only called by the sub-controllers. |
|||
For example when an existing session gets opened""" |
|||
if page == "choose": |
|||
pageIndex = 0 |
|||
elif page == "open": |
|||
pageIndex = 1 |
|||
else: |
|||
raise ValueError(f"_switch accepts choose or open, not {page}") |
|||
|
|||
self.mainWindow.ui.detailedStackedWidget.setCurrentIndex(pageIndex) |
|||
self.mainWindow.ui.quickStackedWidget.setCurrentIndex(pageIndex) |
@ -0,0 +1,482 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), |
|||
more specifically its template base application. |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
#Standard Library |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
#Engine |
|||
import engine.api as api |
|||
|
|||
#Qt |
|||
from .descriptiontextwidget import DescriptionController |
|||
|
|||
iconSize = QtCore.QSize(16,16) |
|||
|
|||
class ClientItem(QtWidgets.QTreeWidgetItem): |
|||
""" |
|||
clientDict = { |
|||
"clientId":clientId, #for convenience, included internally as well |
|||
"dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this. |
|||
"reportedName":None, #str |
|||
"label":None, #str |
|||
"lastStatus":None, #str |
|||
"statusHistory":[], #list |
|||
"hasOptionalGUI": False, #bool |
|||
"visible": None, # bool |
|||
"dirty": None, # bool |
|||
} |
|||
""" |
|||
|
|||
allItems = {} # clientId : ClientItem |
|||
|
|||
def __init__(self, parentController, clientDict:dict): |
|||
ClientItem.allItems[clientDict["clientId"]] = self |
|||
self.parentController = parentController |
|||
self.clientDict = clientDict |
|||
parameterList = [] #later in update |
|||
super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type) |
|||
self.defaultFlags = self.flags() |
|||
self.setFlags(self.defaultFlags | QtCore.Qt.ItemIsEditable) #We have editTrigger to none so we can explicitly allow to only edit the name column on menuAction |
|||
#self.treeWidget() not ready at this point |
|||
self.updateData(clientDict) |
|||
|
|||
def dataClientNameOverride(self, name:str): |
|||
"""Either string or None. If None we reset to nsmd name""" |
|||
logger.info(f"Custom name for id {self.clientDict['clientId']} {self.clientDict['reportedName']}: {name}") |
|||
if name: |
|||
text = name |
|||
else: |
|||
text = self.clientDict["reportedName"] |
|||
|
|||
index = self.parentController.clientsTreeWidgetColumns.index("reportedName") |
|||
self.setText(index, text) |
|||
|
|||
def updateData(self, clientDict:dict): |
|||
"""Arrives via parenTreeWidget api callback""" |
|||
self.clientDict = clientDict |
|||
|
|||
for index, key in enumerate(self.parentController.clientsTreeWidgetColumns): |
|||
if clientDict[key] is None: |
|||
t = "" |
|||
else: |
|||
value = clientDict[key] |
|||
if key == "visible": |
|||
if value == True: |
|||
t = QtCore.QCoreApplication.translate("OpenSession", "✔") |
|||
else: |
|||
t = QtCore.QCoreApplication.translate("OpenSession", "✖") |
|||
elif key == "dirty": |
|||
if value == True: |
|||
t = QtCore.QCoreApplication.translate("OpenSession", "not saved") |
|||
else: |
|||
t = QtCore.QCoreApplication.translate("OpenSession", "clean") |
|||
elif key == "reportedName" and self.parentController.clientOverrideNamesCache: |
|||
if clientDict["clientId"] in self.parentController.clientOverrideNamesCache: |
|||
t = self.parentController.clientOverrideNamesCache[clientDict["clientId"]] |
|||
logger.info(f"Update Data: custom name for id {self.clientDict['clientId']} {self.clientDict['reportedName']}: {t}") |
|||
else: |
|||
t = str(value) |
|||
else: |
|||
t = str(value) |
|||
self.setText(index, t) |
|||
|
|||
programIcons = self.parentController.mainWindow.programIcons |
|||
assert programIcons |
|||
assert "executable" in clientDict, clientDict |
|||
if clientDict["executable"] in programIcons: |
|||
icon = programIcons[clientDict["executable"]] |
|||
self.setIcon(self.parentController.clientsTreeWidgetColumns.index("reportedName"), icon) #reported name is correct here. this is just the column. |
|||
|
|||
|
|||
#TODO: this should be an nsmd status. Check if excecutable exists. nsmd just reports "stopped", and worse: after a long timeout. |
|||
nameColumn = self.parentController.clientsTreeWidgetColumns.index("reportedName") |
|||
if not self.text(nameColumn): #Empty string because program not found |
|||
self.setText(nameColumn, clientDict["executable"]) |
|||
self.setText(self.parentController.clientsTreeWidgetColumns.index("lastStatus"), QtCore.QCoreApplication.translate("OpenSession", "(command not found)")) |
|||
|
|||
#We cannot disable the item because then it can't be selected for resume |
|||
#self.setDisabled(clientDict["lastStatus"] == "stopped") |
|||
|
|||
class ClientTable(object): |
|||
"""Controls the QTreeWidget that holds loaded clients""" |
|||
|
|||
def __init__(self, mainWindow, parent): |
|||
self.mainWindow = mainWindow |
|||
self.parent = parent |
|||
|
|||
self.clientOverrideNamesCache = None #None or dict. Dict is never truly empty, it has at least empty categories. |
|||
|
|||
self.sortByColumn = 0 #by name |
|||
self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending |
|||
self.clientsTreeWidget = self.mainWindow.ui.loadedSessionClients |
|||
self.clientsTreeWidget.setIconSize(iconSize) |
|||
self.clientsTreeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) |
|||
self.clientsTreeWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) #We only allow explicit editing. |
|||
self.clientsTreeWidget.customContextMenuRequested.connect(self.clientsContextMenu) |
|||
self.clientsTreeWidgetColumns = ("reportedName", "label", "lastStatus", "visible", "dirty", "clientId") #basically an enum |
|||
self.clientHeaderLabels = [ |
|||
QtCore.QCoreApplication.translate("OpenSession", "Name"), |
|||
QtCore.QCoreApplication.translate("OpenSession", "Label"), |
|||
QtCore.QCoreApplication.translate("OpenSession", "Status"), |
|||
QtCore.QCoreApplication.translate("OpenSession", "Visible"), |
|||
QtCore.QCoreApplication.translate("OpenSession", "Changes"), |
|||
QtCore.QCoreApplication.translate("OpenSession", "ID"), |
|||
] |
|||
self.clientsTreeWidget.setHeaderLabels(self.clientHeaderLabels) |
|||
self.clientsTreeWidget.setSortingEnabled(True) |
|||
self.clientsTreeWidget.setAlternatingRowColors(True) |
|||
|
|||
#Signals |
|||
self.clientsTreeWidget.currentItemChanged.connect(self._reactSignal_currentClientChanged) |
|||
self.clientsTreeWidget.itemDoubleClicked.connect(self._reactSignal_itemDoubleClicked) #This is hide/show and NOT edit |
|||
self.clientsTreeWidget.itemDelegate().closeEditor.connect(self._reactSignal_itemEditingFinished) |
|||
self.clientsTreeWidget.model().layoutAboutToBeChanged.connect(self._reactSignal_rememberSorting) |
|||
self.clientsTreeWidget.model().layoutChanged.connect(self._reactSignal_restoreSorting) |
|||
#Convenience Signals to directly disable the client messages on gui instruction. |
|||
#This is purely for speed and preventing the user from sending a signal while the session is shutting down |
|||
self.mainWindow.ui.actionSessionAbort.triggered.connect(lambda: self._updateClientMenu(deactivate=True)) |
|||
self.mainWindow.ui.actionSessionSaveAndClose.triggered.connect(lambda: self._updateClientMenu(deactivate=True)) |
|||
|
|||
#API Callbacks |
|||
api.callbacks.sessionOpenLoading.append(self._cleanClients) |
|||
api.callbacks.sessionOpenReady.append(self._updateClientMenu) |
|||
api.callbacks.sessionClosed.append(lambda: self._updateClientMenu(deactivate=True)) |
|||
api.callbacks.clientStatusChanged.append(self._reactCallback_clientStatusChanged) |
|||
api.callbacks.dataClientNamesChanged.append(self._reactCallback_dataClientNamesChanged) |
|||
|
|||
def _adjustColumnSize(self): |
|||
self.clientsTreeWidget.sortByColumn(self.sortByColumn, self.sortAscending) |
|||
for index in range(self.clientsTreeWidget.columnCount()): |
|||
self.clientsTreeWidget.resizeColumnToContents(index) |
|||
|
|||
def _cleanClients(self, nsmSessionExportDict:dict): |
|||
"""Reset everything to the initial, empty state. |
|||
We do not reset in in openReady because that signifies that the session is ready. |
|||
And not in session closed because we want to setup data structures.""" |
|||
ClientItem.allItems.clear() |
|||
self.clientsTreeWidget.clear() |
|||
|
|||
def clientsContextMenu(self, qpoint): |
|||
"""Reuses the menubar menu""" |
|||
item = self.clientsTreeWidget.itemAt(qpoint) |
|||
if not type(item) is ClientItem: |
|||
return |
|||
if not item is self.clientsTreeWidget.currentItem(): |
|||
#Some mouse combinations can lead to getting a different context menu than the clicked item. |
|||
self.clientsTreeWidget.setCurrentItem(item) |
|||
|
|||
menu = self.mainWindow.ui.menuClientNameId |
|||
pos = QtGui.QCursor.pos() |
|||
pos.setY(pos.y() + 5) |
|||
menu.exec_(pos) |
|||
|
|||
|
|||
def _startEditingName(self, *args): |
|||
currentItem = self.clientsTreeWidget.currentItem() |
|||
self.editableItem = currentItem |
|||
column = self.clientsTreeWidgetColumns.index("reportedName") |
|||
self.clientsTreeWidget.editItem(currentItem, column) |
|||
|
|||
def _reactSignal_itemEditingFinished(self, qLineEdit, returnCode): |
|||
"""This is a hacky signal. It arrives every change, programatically or manually. |
|||
We therefore only connect this signal right after a double click and disconnect it |
|||
afterwards. |
|||
And we still need to block signals while this is running. |
|||
|
|||
returnCode: no clue? Integers all over the place... |
|||
""" |
|||
treeWidgetItem = self.editableItem |
|||
self.editableItem = None |
|||
self.clientsTreeWidget.blockSignals(True) |
|||
if treeWidgetItem: |
|||
#We send the signal directly. Updating is done via callback. |
|||
newName = treeWidgetItem.text(0) |
|||
if not newName == treeWidgetItem.clientDict["reportedName"]: |
|||
api.clientNameOverride(treeWidgetItem.clientDict["clientId"], newName) |
|||
self.clientsTreeWidget.blockSignals(False) |
|||
|
|||
def _reactSignal_currentClientChanged(self, treeWidgetItem, previousItem): |
|||
"""Cache the current id for the client menu and shortcuts""" |
|||
if treeWidgetItem: |
|||
self.currentClientId = treeWidgetItem.clientDict["clientId"] |
|||
else: |
|||
self.currentClientId = None |
|||
self._updateClientMenu() |
|||
|
|||
def _reactSignal_itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int): |
|||
if item.clientDict["hasOptionalGUI"]: |
|||
api.clientToggleVisible(item.clientDict["clientId"]) |
|||
|
|||
def _reactCallback_clientStatusChanged(self, clientDict:dict): |
|||
"""The major client callback. Maps to nsmd status changes. |
|||
We will create and delete client tableWidgetItems based on this |
|||
""" |
|||
assert clientDict |
|||
clientId = clientDict["clientId"] |
|||
if clientId in ClientItem.allItems: |
|||
if clientDict["lastStatus"] == "removed": |
|||
index = self.clientsTreeWidget.indexOfTopLevelItem(ClientItem.allItems[clientId]) |
|||
self.clientsTreeWidget.takeTopLevelItem(index) |
|||
else: |
|||
ClientItem.allItems[clientId].updateData(clientDict) |
|||
self._updateClientMenu() #Update here is fine because shutdown sets to status removed. |
|||
else: |
|||
#Create new. Item will be parented by Qt, so Python GC will not delete |
|||
item = ClientItem(parentController=self, clientDict=clientDict) |
|||
self.clientsTreeWidget.addTopLevelItem(item) |
|||
|
|||
self._adjustColumnSize() |
|||
|
|||
#Do not put a general menuUpdate here. It will re-open the client menu during shutdown, enabling the user to send false commands to the client. |
|||
|
|||
|
|||
def _reactCallback_dataClientNamesChanged(self, clientOverrideNames:dict): |
|||
"""We either expect a dict or None. If None we return after clearing the data. |
|||
We clear every callback and re-build. |
|||
|
|||
The dict can be content-empty of course.""" |
|||
logger.info(f"Received dataStorage names update: {clientOverrideNames}") |
|||
|
|||
#Clear current GUI data. |
|||
for clientInstance in ClientItem.allItems.values(): |
|||
clientInstance.dataClientNameOverride(None) |
|||
|
|||
if clientOverrideNames is None: #This only happens if there was a client present and that exits. |
|||
self.clientOverrideNamesCache = None |
|||
else: |
|||
#Real data |
|||
#assert "origin" in data, data . Not in a fresh session, after adding! |
|||
#assert data["origin"] == "https://www.laborejo.org/argodejo/nsm-data", data["origin"] |
|||
self.clientOverrideNamesCache = clientOverrideNames #Can be empty dict as well |
|||
clients = ClientItem.allItems |
|||
for clientId, name in clientOverrideNames.items(): |
|||
#It is possible on session start, that a client has not yet loaded but we already receive a name override. nsm-data is instructed to only announce after session has loaded, but that can go wrong when nsmd has a bad day. |
|||
#Long story short: better to not rename right now, have some name mismatch and wait for a general update later, which will happen after every client load anyway. |
|||
if clientId in clients: |
|||
clients[clientId].dataClientNameOverride(name) |
|||
|
|||
self._updateClientMenu() #Update because we need to en/disable the rename action |
|||
self._adjustColumnSize() |
|||
|
|||
def _updateClientMenu(self, deactivate=False): |
|||
"""The client menu changes with every currentItem edit to reflect the name and capabilities""" |
|||
|
|||
ui = self.mainWindow.ui |
|||
menu = ui.menuClientNameId |
|||
|
|||
if deactivate: |
|||
currentItem = None |
|||
else: |
|||
currentItem = self.clientsTreeWidget.currentItem() |
|||
|
|||
if currentItem: |
|||
clientId = currentItem.clientDict["clientId"] |
|||
state = True |
|||
#if currentItem.clientDict["label"]: |
|||
# name = currentItem.clientDict["label"] |
|||
#else: |
|||
# name = currentItem.clientDict["reportedName"] |
|||
name = currentItem.text(self.clientsTreeWidgetColumns.index("reportedName")) |
|||
else: |
|||
state = False |
|||
name = "Client" |
|||
|
|||
menu.setTitle(name) |
|||
#menu.setEnabled(state) #It is not enough to disable the menu itself. Shortcuts will still work. |
|||
for action in menu.actions(): |
|||
action.setEnabled(state) |
|||
|
|||
if state: |
|||
ui.actionClientRename.triggered.disconnect() |
|||
ui.actionClientRename.triggered.connect(self._startEditingName) |
|||
#ui.actionClientRename.triggered.connect(lambda: self.clientsTreeWidget.editItem(currentItem, self.clientsTreeWidgetColumns.index("reportedName"))) |
|||
ui.actionClientSave_separately.triggered.disconnect() |
|||
ui.actionClientSave_separately.triggered.connect(lambda: api.clientSave(clientId)) |
|||
ui.actionClientStop.triggered.disconnect() |
|||
ui.actionClientStop.triggered.connect(lambda: api.clientStop(clientId)) |
|||
#ui.actionClientStop.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback |
|||
ui.actionClientResume.triggered.disconnect() |
|||
ui.actionClientResume.triggered.connect(lambda: api.clientResume(clientId)) |
|||
#ui.actionClientResume.triggered.connect(self._updateClientMenu) #too early. We need to wait for the status callback |
|||
ui.actionClientRemove.triggered.disconnect() |
|||
ui.actionClientRemove.triggered.connect(lambda: api.clientRemove(clientId)) |
|||
|
|||
#Deactivate depending on the state of the program |
|||
if currentItem.clientDict["lastStatus"] == "stopped": |
|||
ui.actionClientSave_separately.setEnabled(False) |
|||
ui.actionClientStop.setEnabled(False) |
|||
ui.actionClientToggleVisible.setEnabled(False) |
|||
else: |
|||
#Hide and show shall only be enabled and connected if supported by the client |
|||
try: |
|||
ui.actionClientToggleVisible.triggered.disconnect() |
|||
except TypeError: #TypeError: disconnect() failed between 'triggered' and all its connections |
|||
pass |
|||
if currentItem.clientDict["hasOptionalGUI"]: |
|||
ui.actionClientToggleVisible.setEnabled(True) |
|||
ui.actionClientToggleVisible.triggered.connect(lambda: api.clientToggleVisible(clientId)) |
|||
else: |
|||
ui.actionClientToggleVisible.setEnabled(False) |
|||
|
|||
#Only rename when dataclient is present |
|||
#None or dict, even empty dict |
|||
if self.clientOverrideNamesCache is None: |
|||
ui.actionClientRename.setEnabled(False) |
|||
else: |
|||
ui.actionClientRename.setEnabled(True) |
|||
|
|||
def _reactSignal_rememberSorting(self, *args): |
|||
self.sortByColumn = self.clientsTreeWidget.header().sortIndicatorSection() |
|||
self.sortDescending = self.clientsTreeWidget.header().sortIndicatorOrder() |
|||
|
|||
def _reactSignal_restoreSorting(self, *args): |
|||
self.clientsTreeWidget.sortByColumn(self.sortByColumn, self.sortDescending) |
|||
|
|||
class LauncherProgram(QtWidgets.QTreeWidgetItem): |
|||
""" |
|||
Example: |
|||
{ 'categories': 'AudioVideo;Audio;X-Recorders;X-Multitrack;X-Jack;', |
|||
'comment': 'Easy to use pattern sequencer for JACK and NSM', |
|||
'comment[de]': 'Einfach zu bedienender Pattern-Sequencer', |
|||
'exec': 'patroneo', |
|||
'genericname': 'Sequencer', |
|||
'icon': 'patroneo', |
|||
'name': 'Patroneo', |
|||
'startupnotify': 'false', |
|||
'terminal': 'false', |
|||
'type': 'Application', |
|||
'version': '1.4.1', |
|||
'x-nsm-capable': 'true'} |
|||
""" |
|||
|
|||
allItems = {} # clientId : ClientItem |
|||
|
|||
def __init__(self, parentController, launcherDict:dict): |
|||
LauncherProgram.allItems[launcherDict["argodejoExec"]] = self |
|||
self.parentController = parentController |
|||
self.launcherDict = launcherDict |
|||
self.executable = launcherDict["argodejoExec"] |
|||
|
|||
parameterList = [] #later in update |
|||
super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type) |
|||
self.updateData(launcherDict) |
|||
|
|||
def updateData(self, launcherDict:dict): |
|||
"""Arrives via parenTreeWidget api callback""" |
|||
self.launcherDict = launcherDict |
|||
|
|||
for index, key in enumerate(self.parentController.columns): |
|||
if (not key in launcherDict) or launcherDict[key] is None: |
|||
t = "" |
|||
else: |
|||
t = str(launcherDict[key]) |
|||
self.setText(index, t) |
|||
|
|||
programIcons = self.parentController.mainWindow.programIcons |
|||
assert programIcons |
|||
if launcherDict["argodejoExec"] in programIcons: |
|||
icon = programIcons[launcherDict["argodejoExec"]] |
|||
self.setIcon(self.parentController.columns.index("name"), icon) #name is correct here. this is just the column. |
|||
|
|||
class LauncherTable(object): |
|||
"""Controls the QTreeWidget that holds programs in the PATH. |
|||
""" |
|||
def __init__(self, mainWindow, parent): |
|||
self.mainWindow = mainWindow |
|||
self.parent = parent |
|||
|
|||
self.sortByColumn = 0 # by name |
|||
self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending |
|||
self.launcherWidget = self.mainWindow.ui.loadedSessionsLauncher |
|||
self.launcherWidget.setIconSize(iconSize) |
|||
|
|||
self.columns = ("name", "comment", "version", "argodejoFullPath") #basically an enum |
|||
self.headerLables = [ |
|||
QtCore.QCoreApplication.translate("Launcher", "Name"), |
|||
QtCore.QCoreApplication.translate("Launcher", "Description"), |
|||
QtCore.QCoreApplication.translate("Launcher", "Version"), |
|||
QtCore.QCoreApplication.translate("Launcher", "Path"), |
|||
] |
|||
self.launcherWidget.setHeaderLabels(self.headerLables) |
|||
self.launcherWidget.setSortingEnabled(True) |
|||
self.launcherWidget.setAlternatingRowColors(True) |
|||
|
|||
#The actual program entries are handled by the LauncherProgram item class |
|||
self.buildPrograms() |
|||
|
|||
#Signals |
|||
self.launcherWidget.itemDoubleClicked.connect(self._reactSignal_launcherItemDoubleClicked) |
|||
|
|||
|
|||
def _adjustColumnSize(self): |
|||
self.launcherWidget.sortByColumn(self.sortByColumn, self.sortAscending) |
|||
for index in range(self.launcherWidget.columnCount()): |
|||
self.launcherWidget.resizeColumnToContents(index) |
|||
|
|||
def _reactSignal_launcherItemDoubleClicked(self, item): |
|||
api.clientAdd(item.executable) |
|||
|
|||
def buildPrograms(self): |
|||
"""Called by mainWindow.updateProgramDatabase |
|||
|
|||
Receive entries from the engine. |
|||
Entry is a dict modelled after a .desktop file. |
|||
But not all entries have all data. Some are barebones executable name and path. |
|||
|
|||
Only guaranteed keys are argodejoExec and argodejoFullPath, which in turn are files |
|||
guaranteed to exist in the path. |
|||
""" |
|||
self.launcherWidget.clear() |
|||
programs = api.getSystemPrograms() |
|||
|
|||
for entry in programs: |
|||
item = LauncherProgram(parentController=self, launcherDict=entry) |
|||
self.launcherWidget.addTopLevelItem(item) |
|||
|
|||
self._adjustColumnSize() |
|||
|
|||
class OpenSessionController(object): |
|||
"""Not a subclass. Controls the visible tab, when a session is open. |
|||
There is only one open instance at a time that controls the GUI and cleans itself.""" |
|||
|
|||
def __init__(self, mainWindow): |
|||
self.mainWindow = mainWindow |
|||
self.clientTabe = ClientTable(mainWindow=mainWindow, parent=self) |
|||
self.launcherTable = LauncherTable(mainWindow=mainWindow, parent=self) |
|||
|
|||
self.descriptionController = DescriptionController(mainWindow, self.mainWindow.ui.loadedSessionDescriptionGroupBox, self.mainWindow.ui.loadedSessionDescription) |
|||
|
|||
self.sessionLoadedPanel = mainWindow.ui.session_loaded #groupbox |
|||
self.sessionProgramsPanel = mainWindow.ui.session_programs #groupbox |
|||
|
|||
#API Callbacks |
|||
api.callbacks.sessionOpenReady.append(self._reactCallback_sessionOpen) |
|||
logger.info("Full View Open Session Controller ready") |
|||
|
|||
def _reactCallback_sessionOpen(self, nsmSessionExportDict:dict): |
|||
"""Open does not mean we come from the session chooser. Switching does not close a session""" |
|||
#self.description.clear() #Deletes the placesholder and text! |
|||
self.sessionLoadedPanel.setTitle(nsmSessionExportDict["nsmSessionName"]) |
@ -0,0 +1,160 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), |
|||
more specifically its template base application. |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
|
|||
#Standard Library |
|||
import pathlib |
|||
import os |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtWidgets |
|||
|
|||
#Qt |
|||
from .designer.projectname import Ui_ProjectName |
|||
from .designer.newsession import Ui_NewSession |
|||
|
|||
#Engine |
|||
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. |
|||
import engine.api as api |
|||
|
|||
class ProjectNameWidget(QtWidgets.QDialog): |
|||
"""Ask the user for a project name. Either for renaming, new or copy. |
|||
Will have a live-detection """ |
|||
|
|||
def __init__(self, parent, startwith:str="", start=True, alsoDisable=None): |
|||
super().__init__(parent) |
|||
self.ui = Ui_ProjectName() |
|||
self.ui.setupUi(self) |
|||
|
|||
self.alsoDisable = alsoDisable #in case we are embedded |
|||
|
|||
#self.ui.labelDescription |
|||
|
|||
self.ui.labelError.setText("") |
|||
self.ui.name.setText(startwith) |
|||
self.check(startwith) |
|||
self.ui.name.textEdited.connect(self.check) #not called when text is changed programatically |
|||
|
|||
self.result = None |
|||
|
|||
self.ui.buttonBox.accepted.connect(self.process) |
|||
self.ui.buttonBox.rejected.connect(self.reject) |
|||
|
|||
if start: |
|||
self.setWindowFlag(QtCore.Qt.Popup, True) |
|||
self.setModal(True) |
|||
self.setFocus(True) |
|||
self.ui.name.setFocus(True) |
|||
self.exec_() |
|||
|
|||
def check(self, currentText): |
|||
"""Called every keypress. |
|||
We do preliminary error and collision checking here, so the engine does not have to throw |
|||
an error """ |
|||
if currentText.endswith("/"): |
|||
currentText = currentText[:-1] |
|||
|
|||
path = pathlib.Path(api.sessionRoot(), currentText) |
|||
|
|||
errorMessage = "" |
|||
if currentText == "": |
|||
errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Name must not be empty.") |
|||
elif pathlib.PurePosixPath(path).match("/*"): |
|||
errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Name must be a relative path.") |
|||
elif pathlib.PurePosixPath(path).match("../*") or pathlib.PurePosixPath(path).match("*..*"): |
|||
errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Moving to parent directory not allowed.") |
|||
elif "/" in currentText and path.parent.exists() and not os.access(path.parent, os.W_OK): |
|||
errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Writing in this directory is not permitted.") |
|||
elif path.exists(): |
|||
errorMessage = QtCore.QCoreApplication.translate("ProjectNameWidget", "Name is already in use.") |
|||
|
|||
ok = self.ui.buttonBox.button(QtWidgets.QDialogButtonBox.Ok) |
|||
if errorMessage: |
|||
if self.alsoDisable: |
|||
self.alsoDisable.setEnabled(False) |
|||
ok.setEnabled(False) |
|||
self.ui.labelError.setText("<i>"+errorMessage+"</i>") |
|||
else: |
|||
#print (path) |
|||
if self.alsoDisable: |
|||
self.alsoDisable.setEnabled(True) |
|||
self.ui.labelError.setText("") |
|||
ok.setEnabled(True) |
|||
|
|||
def process(self): |
|||
"""Careful! Calling this eats python errors without notice. Make sure your objects exists |
|||
and your syntax is correct""" |
|||
self.result = self.ui.name.text() |
|||
self.done(True) |
|||
|
|||
class NewSessionDialog(QtWidgets.QDialog): |
|||
|
|||
def __init__(self, parent, startwith:str=""): |
|||
super().__init__(parent) |
|||
self.ui = Ui_NewSession() |
|||
self.ui.setupUi(self) |
|||
|
|||
#send our childWidget our own ok button so it can disable it for the name check |
|||
self.ok = self.ui.buttonBox.button(QtWidgets.QDialogButtonBox.Ok) |
|||
#Embed a project name widget |
|||
self.projectName = ProjectNameWidget(self, startwith, start=False, alsoDisable=self.ok) |
|||
self.projectName.ui.buttonBox.hide() |
|||
self.ui.nameGroupBox.layout().addWidget(self.projectName) |
|||
self.projectName.ui.name.returnPressed.connect(self.ok.click) #We want to accept everything when return is pressed. |
|||
|
|||
self.result = None |
|||
|
|||
nsmExecutables = api.getNsmExecutables() #type set, cached, very fast. |
|||
data = METADATA["preferredClients"]["connections"] |
|||
con = METADATA["preferredClients"]["data"] |
|||
|
|||
self.ui.checkBoxJack.setEnabled(con in nsmExecutables) |
|||
self.ui.checkBoxJack.setVisible(con in nsmExecutables) |
|||
self.ui.checkBoxData.setEnabled(data in nsmExecutables) |
|||
self.ui.checkBoxData.setVisible(data in nsmExecutables) |
|||
|
|||
self.ui.buttonBox.accepted.connect(self.process) |
|||
self.ui.buttonBox.rejected.connect(self.reject) |
|||
|
|||
self.setModal(True) |
|||
self.setWindowFlag(QtCore.Qt.Popup, True) |
|||
self.projectName.ui.name.setFocus(True) |
|||
self.exec_() |
|||
|
|||
|
|||
def process(self): |
|||
"""Careful! Calling this eats python errors without notice. Make sure your objects exists |
|||
and your syntax is correct""" |
|||
assert self.ok.isEnabled() |
|||
|
|||
data = METADATA["preferredClients"]["connections"] |
|||
con = METADATA["preferredClients"]["data"] |
|||
startclients = [] |
|||
if self.ui.checkBoxJack.isChecked(): startclients.append(con) |
|||
if self.ui.checkBoxData.isChecked(): startclients.append(data) |
|||
self.result = { |
|||
"name" : self.projectName.ui.name.text(), |
|||
"startclients" : startclients, |
|||
} |
|||
self.done(True) |
@ -0,0 +1,321 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ). |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
#Standard Library |
|||
|
|||
#Engine |
|||
import engine.api as api |
|||
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. |
|||
|
|||
#QtGui |
|||
from .descriptiontextwidget import DescriptionController |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
def nothing(): |
|||
pass |
|||
|
|||
class StarterClientItem(QtWidgets.QListWidgetItem): |
|||
""".desktop-like entry: |
|||
{'type': 'Application', |
|||
'name': 'Vico', |
|||
'genericname': 'Sequencer', |
|||
'comment':'Minimalistic midi sequencer with piano roll for JACK and NSM', |
|||
'exec': 'vico', |
|||
'icon': 'vico', |
|||
'terminal': 'false', |
|||
'startupnotify': 'false', |
|||
'categories': 'AudioVideo;Audio;X-Recorders;X-Multitrack;X-Jack;', |
|||
'x-nsm-capable': 'true', 'version': '1.0.1', |
|||
'argodejoFullPath': '/usr/bin/vico', |
|||
'argodejoExec': 'vico', |
|||
'whitelist': True} |
|||
|
|||
This is the icon that is starter and status-indicator at once. |
|||
QuickSession has only one icon per argodejoExec. |
|||
|
|||
If at least one program is running as nsmClient in the session we switch ourselves on and |
|||
save the status as self.nsmClientDict |
|||
|
|||
We do not react to name overrides by nsm-data, nor do we react to labels or name changes |
|||
through reportedNames. |
|||
""" |
|||
allItems = {} #argodejoExec:StarterClientItem |
|||
|
|||
def __init__(self, parentController, desktopEntry:dict): |
|||
self.parentController = parentController |
|||
self.desktopEntry = desktopEntry |
|||
self.argodejoExec = desktopEntry["argodejoExec"] |
|||
super().__init__(desktopEntry["name"], type=1000) #type 0 is default qt type. 1000 is subclassed user type) |
|||
|
|||
self.nsmClientDict = None #aka nsmStatusDict if this exists it means at least one instance of this application is running in the session |
|||
|
|||
if "comment" in desktopEntry: |
|||
self.setToolTip(desktopEntry["comment"]) |
|||
else: |
|||
self.setToolTip(desktopEntry["name"]) |
|||
programIcons = self.parentController.mainWindow.programIcons |
|||
assert programIcons |
|||
assert "argodejoExec" in desktopEntry, desktopEntry |
|||
if desktopEntry["argodejoExec"] in programIcons: |
|||
icon = programIcons[desktopEntry["argodejoExec"]] |
|||
self.setIcon(icon) |
|||
self.updateStatus(None) #removed/off |
|||
|
|||
def updateStatus(self, clientDict:dict): |
|||
""" |
|||
api callback |
|||
|
|||
clientDict = { |
|||
"clientId":clientId, #for convenience, included internally as well |
|||
"dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this. |
|||
"reportedName":None, #str |
|||
"executable":None, #For dumb clients this is the same as reportedName. |
|||
"label":None, #str |
|||
"lastStatus":None, #str |
|||
"statusHistory":[], #list |
|||
"hasOptionalGUI": False, #bool |
|||
"visible": None, # bool |
|||
"dirty": None, # bool |
|||
} |
|||
""" |
|||
self.nsmClientDict = clientDict #for comparison with later status changes. Especially for stopped clients. |
|||
if clientDict is None: |
|||
self.removed() |
|||
else: |
|||
getattr(self, clientDict["lastStatus"], nothing)() |
|||
|
|||
def _setIconOverlay(self, status:str): |
|||
"""https://doc.qt.io/qt-5/qstyle.html#StandardPixmap-enum""" |
|||
standardPixmap, enabled, removeAlpha = { |
|||
"removed":(QtWidgets.QStyle.SP_TrashIcon, False, True), |
|||
"stopped":(QtWidgets.QStyle.SP_BrowserStop, False, False), |
|||
"ready": (None, True, False), |
|||
"hidden":(QtWidgets.QStyle.SP_TitleBarMaxButton, True, True), |
|||
}[status] |
|||
|
|||
if standardPixmap: |
|||
overlayPixmap = self.parentController.listWidget.style().standardPixmap(standardPixmap) |
|||
|
|||
if removeAlpha: |
|||
whiteBg = QtGui.QPixmap(overlayPixmap.size()) |
|||
whiteBg.fill(QtGui.QColor(255,255,255,255)) #red |
|||
|
|||
icon = self.parentController.mainWindow.programIcons[self.argodejoExec] |
|||
if enabled: |
|||
pixmap = icon.pixmap(QtCore.QSize(70,70)) |
|||
else: |
|||
pixmap = icon.pixmap(QtCore.QSize(70,70), QtGui.QIcon.Disabled) |
|||
|
|||
p = QtGui.QPainter(pixmap) |
|||
if removeAlpha: |
|||
p.drawPixmap(0, 0, whiteBg) |
|||
p.drawPixmap(0, 0, overlayPixmap) |
|||
p.end() |
|||
ico = QtGui.QIcon(pixmap) |
|||
else: |
|||
ico = self.parentController.mainWindow.programIcons[self.argodejoExec] |
|||
|
|||
self.setIcon(ico) |
|||
|
|||
|
|||
#Status |
|||
def ready(self): |
|||
if self.nsmClientDict["hasOptionalGUI"]: |
|||
if self.nsmClientDict["visible"]: |
|||
self._setIconOverlay("ready") |
|||
else: |
|||
self._setIconOverlay("hidden") |
|||
|
|||
self.setFlags(QtCore.Qt.ItemIsEnabled) #We can still mouseClick through parent signal when set to NoItemFlags |
|||
|
|||
def removed(self): |
|||
self.setFlags(QtCore.Qt.NoItemFlags) #We can still mouseClick through parent signal when set to NoItemFlags |
|||
self.nsmClientDict = None #in opposite to stop |
|||
|
|||
def stopped(self): |
|||
self.setFlags(QtCore.Qt.ItemIsEnabled) |
|||
self._setIconOverlay("stopped") |
|||
|
|||
def handleClick(self): |
|||
alreadyInSession = api.executableInSession(self.argodejoExec) |
|||
#Development-paranoia Start |
|||
if self.nsmClientDict: |
|||
assert alreadyInSession |
|||
elif alreadyInSession: |
|||
assert self.nsmClientDict |
|||
#Development-paranoia End |
|||
|
|||
if not alreadyInSession: |
|||
api.clientAdd(self.argodejoExec) #triggers status update callback which activates our item. |
|||
elif self.nsmClientDict["lastStatus"] == "stopped": |
|||
api.clientResume(self.nsmClientDict["clientId"]) |
|||
else: |
|||
api.clientToggleVisible(self.nsmClientDict["clientId"]) #api is tolerant to sending this to non-optional-GUI clients |
|||
|
|||
|
|||
class QuickOpenSessionController(object): |
|||
"""Controls the widget, but does not subclass. |
|||
|
|||
We want the simplest form of interaction possible: single touch. |
|||
No selections, no right click. Like a smartphone app. |
|||
""" |
|||
|
|||
def __init__(self, mainWindow): |
|||
iconSize = 70 |
|||
self.mainWindow = mainWindow |
|||
self.listWidget = mainWindow.ui.quickSessionClientsListWidget |
|||
self.listWidget.setIconSize(QtCore.QSize(iconSize,iconSize)) |
|||
self.listWidget.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) |
|||
self.listWidget.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) |
|||
self.listWidget.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) #Icons can't be selected. Text still can |
|||
self.listWidget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) |
|||
self.listWidget.setResizeMode(QtWidgets.QListView.Adjust) |
|||
self.listWidget.setGridSize(QtCore.QSize(iconSize*1.2,iconSize*2)) #x spacing, y for text |
|||
self.listWidget.setWordWrap(True) #needed for grid, don't use without grid (i.e. setSparcing and setUniformItemSizes) |
|||
#self.listWidget.setSpacing(20) # Grid is better |
|||
#self.listWidget.setUniformItemSizes(True) # Grid is better |
|||
|
|||
|
|||
self._nsmSessionExportDict = None |
|||
self.nameWidget = mainWindow.ui.quickSessionNameLineEdit |
|||
self.layout = mainWindow.ui.page_quickSessionLoaded.layout() |
|||
self.clientOverrideNamesCache = None #None or dict. Dict is never truly empty, it has at least empty categories. |
|||
self.descriptionController = DescriptionController(mainWindow, self.mainWindow.ui.quickSessionNotesGroupBox, self.mainWindow.ui.quickSessionNotesPlainTextEdit) |
|||
|
|||
font = self.nameWidget.font() |
|||
font.setPixelSize(font.pixelSize() * 1.4) |
|||
self.nameWidget.setFont(font) |
|||
|
|||
#GUI Signals |
|||
#self.listWidget.itemActivated.connect(lambda item:print (item)) #Activated is system dependend. On osx it might be single clicke, here on linux is double click. on phones it might be something else. |
|||
self.listWidget.itemClicked.connect(self._itemClicked) |
|||
|
|||
mainWindow.ui.quickCloseOpenSession.clicked.connect(api.sessionClose) |
|||
font = mainWindow.ui.quickCloseOpenSession.font() |
|||
font.setPixelSize(font.pixelSize() * 1.2) |
|||
mainWindow.ui.quickCloseOpenSession.setFont(font) |
|||
|
|||
mainWindow.ui.quickSaveOpenSession.clicked.connect(api.sessionSave) |
|||
mainWindow.ui.quickSaveOpenSession.hide() |
|||
|
|||
#API Callbacks |
|||
api.callbacks.sessionOpenLoading.append(self.buildCleanStarterClients) |
|||
api.callbacks.sessionOpenLoading.append(self._openLoading) |
|||
api.callbacks.sessionOpenReady.append(self._openReady) |
|||
api.callbacks.sessionClosed.append(self._sendNameChange) |
|||
api.callbacks.clientStatusChanged.append(self._clientStatusChanged) |
|||
|
|||
self.listWidget.setFocus() #take focus away from title-edit |
|||
|
|||
logger.info("Quick Open Session Controller ready") |
|||
|
|||
def _itemClicked(self, item): |
|||
self.listWidget.reset() #Hackity Hack! This is intended to revert the text-selection of items. However, that is not a real selection. clearSelection does nothing! Now it looks like a brief flash. |
|||
item.handleClick() |
|||
|
|||
|
|||
|
|||
def _openLoading(self, nsmSessionExportDict): |
|||
self._nsmSessionExportDict = nsmSessionExportDict |
|||
self.nameWidget.setText(nsmSessionExportDict["nsmSessionName"]) |
|||
|
|||
def _openReady(self, nsmSessionExportDict): |
|||
self._nsmSessionExportDict = nsmSessionExportDict |
|||
|
|||
def _sendNameChange(self): |
|||
"""The closed callback is send on start to indicate "no open session". exportDict cache is |
|||
not ready then. We need to test. |
|||
It is not possible to rename a running session. We allow the user to fake-edit the name |
|||
but will only send the api request after the session is closed""" |
|||
if not self._nsmSessionExportDict: #see docstring |
|||
return |
|||
|
|||
if self.nameWidget.text() and not self.nameWidget.text() == self._nsmSessionExportDict["nsmSessionName"]: |
|||
logger.info(f"Instructing the api to rename session {self._nsmSessionExportDict['nsmSessionName']} to {self.nameWidget.text()} on close") |
|||
api.sessionRename(self._nsmSessionExportDict["nsmSessionName"], self.nameWidget.text()) |
|||
|
|||
self._nsmSessionExportDict = None #now really closed |
|||
|
|||
def buildCleanStarterClients(self, nsmSessionExportDict:dict): |
|||
"""Reset everything to the initial, empty state. |
|||
We do not reset in openReady because that signifies that the session is ready. |
|||
And not in session closed because we want to setup data structures. |
|||
|
|||
In comparison with the detailed view open session controller we need to do incremental |
|||
updates. The detailed view can just delete and recreater its launchers after a DB-update, |
|||
but we combine both views. So we can't just delete-and-rebuild because that destroys |
|||
running client states. |
|||
""" |
|||
whitelist = [e for e in api.getSystemPrograms() if e["whitelist"]] |
|||
leftovers = set(StarterClientItem.allItems.keys()) #"argodejoExec" |
|||
for entry in whitelist: |
|||
exe = entry["argodejoExec"] |
|||
if exe in StarterClientItem.allItems: |
|||
leftovers.remove(entry["argodejoExec"]) |
|||
else: |
|||
#Create new. Item will be parented by Qt, so Python GC will not delete |
|||
item = StarterClientItem(parentController=self, desktopEntry=entry) |
|||
self.listWidget.addItem(item) |
|||
StarterClientItem.allItems[entry["argodejoExec"]] = item |
|||
|
|||
#Remove icons that were available until they got removed in the last db update |
|||
for loexe in leftovers: |
|||
item = StarterClientItem.allItems[loexe] |
|||
del StarterClientItem.allItems[loexe] |
|||
index = self.listWidget.indexFromItem(item).row() #Row is the real index in a listView, no matter iconViewMode. |
|||
self.listWidget.takeItem(index) |
|||
del item |
|||
|
|||
#old: rebuild from scratch |
|||
""" |
|||
self.listWidget.clear() |
|||
StarterClientItem.allItems.clear() |
|||
whitelist = [e for e in api.getSystemPrograms() if e["whitelist"]] |
|||
for entry in whitelist: |
|||
#Create new. Item will be parented by Qt, so Python GC will not delete |
|||
item = StarterClientItem(parentController=self, desktopEntry=entry) |
|||
self.listWidget.addItem(item) |
|||
StarterClientItem.allItems[entry["argodejoExec"]] = item |
|||
""" |
|||
|
|||
def _clientStatusChanged(self, clientDict:dict): |
|||
"""Maps to nsmd status changes. |
|||
We already have icons for all programs, in opposite to detailed-view opensession controller. |
|||
Status updates are used to switch them on an off. |
|||
We also present only one icon per executable. If you want more go into the other mode. |
|||
""" |
|||
#index = self.listWidget.indexFromItem(QuickClientItem.allItems[clientId]).row() #Row is the real index in a listView, no matter iconViewMode. |
|||
if clientDict["dumbClient"]: |
|||
#This includes the initial loading of nsm-clients. |
|||
return |
|||
backgroundClients = METADATA["preferredClients"].values() |
|||
if clientDict["executable"] in backgroundClients: |
|||
return |
|||
|
|||
if clientDict["executable"] in StarterClientItem.allItems: |
|||
item = StarterClientItem.allItems[clientDict["executable"]] |
|||
item.updateStatus(clientDict) |
|||
else: |
|||
logging.warning(f"Got client status update for {clientDict['executable']}, which is not in our database. This can happen if you install a program and do not update the DB. Please do so and then restart the session.") |
@ -0,0 +1,108 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ). |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
#Standard Library |
|||
import datetime |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
#Engine |
|||
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. |
|||
import engine.api as api |
|||
|
|||
|
|||
|
|||
class SessionButton(QtWidgets.QPushButton): |
|||
|
|||
def __init__(self, sessionDict): |
|||
self.sessionDict = sessionDict |
|||
super().__init__(sessionDict["nsmSessionName"]) |
|||
self.clicked.connect(self.openSession) |
|||
#width = self.fontMetrics().boundingRect(sessionDict["nsmSessionName"]).width()+10 |
|||
#self.setFixedSize(width, 40) |
|||
self.setFixedHeight(40) |
|||
font = self.font() |
|||
font.setPixelSize(font.pixelSize() * 1.2 ) |
|||
self.setFont(font) |
|||
|
|||
def openSession(self): |
|||
name = self.sessionDict["nsmSessionName"] |
|||
api.sessionOpen(name) |
|||
|
|||
|
|||
class QuickSessionController(object): |
|||
"""Controls the widget, but does not subclass""" |
|||
|
|||
def __init__(self, mainWindow): |
|||
self.mainWindow = mainWindow |
|||
self.layout = mainWindow.ui.quickSessionChooser.layout() |
|||
newSessionButton = mainWindow.ui.quickNewSession |
|||
|
|||
font = newSessionButton.font() |
|||
font.setPixelSize(font.pixelSize() * 1.4) |
|||
newSessionButton.setFont(font) |
|||
|
|||
newSessionButton.setFixedHeight(40) |
|||
newSessionButton.setFocus(True) #Enter on program start creates a new session. |
|||
newSessionButton.clicked.connect(self._newTimestampSession) |
|||
api.callbacks.sessionsChanged.append(self._reactCallback_sessionsChanged) |
|||
|
|||
logger.info("Quick Session Chooser ready") |
|||
|
|||
def _clear(self): |
|||
"""Clear everything but the spacer item""" |
|||
for child in self.mainWindow.ui.quickSessionChooser.children(): |
|||
if type(child) is SessionButton: |
|||
self.layout.removeWidget(child) |
|||
child.setParent(None) |
|||
del child |
|||
|
|||
def _reactCallback_sessionsChanged(self, sessionDicts:list): |
|||
"""Main callback for new, added, removed, moved sessions etc.""" |
|||
logger.info("Rebuilding session buttons") |
|||
self._clear() #except the space |
|||
|
|||
spacer = self.layout.takeAt(0) |
|||
|
|||
for sessionDict in sorted(sessionDicts, key=lambda d: d["nsmSessionName"]): |
|||
self.layout.addWidget(SessionButton(sessionDict)) |
|||
|
|||
#Finally add vertical spacer |
|||
#spacerItem = QtWidgets.QSpacerItem(1, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) #int w, int h, QSizePolicy::Policy hPolicy = QSizePolicy::Minimum, QSizePolicy::Policy vPolicy = QSizePolicy::Minimum |
|||
self.layout.addItem(spacer) |
|||
|
|||
def _newTimestampSession(self): |
|||
nsmExecutables = api.getNsmExecutables() #type set, cached, very fast. |
|||
con = METADATA["preferredClients"]["data"] |
|||
data = METADATA["preferredClients"]["connections"] |
|||
startclients = [] |
|||
if con in nsmExecutables: |
|||
startclients.append(con) |
|||
if data in nsmExecutables: |
|||
startclients.append(data) |
|||
|
|||
#now = datetime.datetime.now().replace(second=0, microsecond=0).isoformat()[:-3] |
|||
now = datetime.datetime.now().replace(microsecond=0).isoformat() |
|||
name = now |
|||
api.sessionNew(name, startclients) |
@ -0,0 +1,343 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ). |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
|
|||
#Standard Library |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
#Engine |
|||
import engine.api as api |
|||
|
|||
#Qt |
|||
from .helper import sizeof_fmt |
|||
from .projectname import ProjectNameWidget |
|||
from .projectname import NewSessionDialog |
|||
|
|||
class DirectoryItem(QtWidgets.QTreeWidgetItem): |
|||
"""A plain directory with no session content""" |
|||
|
|||
def __lt__(self, other): |
|||
"""Treeview uses only less than. |
|||
less equal, both greater and equal are not used. |
|||
|
|||
We always want to be on top, no matter what column or sort order |
|||
""" |
|||
if type(other) is SessionItem: |
|||
if self.treeWidget().header().sortIndicatorOrder(): #descending? |
|||
return False |
|||
else: |
|||
return True |
|||
else: |
|||
return QtWidgets.QTreeWidgetItem.__lt__(self, other) |
|||
|
|||
class SessionItem(QtWidgets.QTreeWidgetItem): |
|||
"""Subclass to enable sorting of size by actual value, not by human readable display. |
|||
entry["nsmSessionName"] = projectName |
|||
entry["name"] = os.path.basename(projectName) |
|||
entry["lastSavedDate"] = "2016-05-21 16:36" |
|||
entry["fullPath"] = actual path |
|||
entry["sizeInBytes"] = 623623 |
|||
entry["numberOfClients"] = 3 |
|||
entry["hasSymlinks"] = True |
|||
entry["parents"] = [] |
|||
""" |
|||
|
|||
allItems = {} #nsmSessionName : SessionItem |
|||
|
|||
def __init__(self, sessionDict): |
|||
SessionItem.allItems[sessionDict["nsmSessionName"]] = self |
|||
|
|||
self.sessionDict = sessionDict |
|||
|
|||
symlinks = "Yes" if sessionDict["hasSymlinks"] else "No" #TODO: Translate |
|||
parameterList = [sessionDict["name"], sessionDict["lastSavedDate"], str(sessionDict["numberOfClients"]), sizeof_fmt(sessionDict["sizeInBytes"]), symlinks, sessionDict["fullPath"], ] |
|||
super().__init__(parameterList, type=1000) #type 0 is default qt type. 1000 is subclassed user type) |
|||
|
|||
self.setTextAlignment(2, QtCore.Qt.AlignHCenter) #clients |
|||
self.setTextAlignment(4, QtCore.Qt.AlignHCenter) #symlinks |
|||
|
|||
self.setLocked(sessionDict["locked"]) |
|||
|
|||
|
|||
def updateData(self): |
|||
"""Actively queries the api for new data""" |
|||
sessionDict = api.sessionQuery(self.sessionDict["nsmSessionName"]) |
|||
self.sessionDict = sessionDict |
|||
self.setText(0, sessionDict["name"]) |
|||
self.setText(1, sessionDict["lastSavedDate"]) |
|||
self.setText(2, str(sessionDict["numberOfClients"])) |
|||
self.setText(3, sizeof_fmt(sessionDict["sizeInBytes"])) |
|||
self.setText(4, "Yes" if sessionDict["hasSymlinks"] else "No") #TODO: Translate |
|||
self.setText(5, sessionDict["fullPath"]) |
|||
|
|||
def setLocked(self, state:bool): |
|||
"""Number of clients, symlinks and size change frequently while a session is open/locked. |
|||
We deactivate the display of these values while locked""" |
|||
if not state == self.isDisabled(): |
|||
self.setDisabled(state) |
|||
if state: |
|||
self.setText(2, "") #number of clients |
|||
self.setText(3, "") #Symlinks |
|||
self.setText(4, "") #Size |
|||
else: |
|||
self.updateData() |
|||
|
|||
def updateTimestamp(self, timestamp:str): |
|||
#Column 1 "Last Save" |
|||
self.setText(1, timestamp) |
|||
|
|||
def __lt__(self, other): |
|||
"""Treeview uses only less than. |
|||
less equal, both greater and equal are not used. |
|||
|
|||
There is no check between two directory-items here because these are standard WidgetItems |
|||
""" |
|||
if type(other) is DirectoryItem: #Just a dir |
|||
return False #we are "greater"=later |
|||
|
|||
column = self.treeWidget().sortColumn() |
|||
if column == 3: #bytes |
|||
return self.sessionDict["sizeInBytes"] > other.sessionDict["sizeInBytes"] |
|||
|
|||
elif column == 2: #number of clients |
|||
return self.sessionDict["numberOfClients"] > other.sessionDict["numberOfClients"] |
|||
else: |
|||
return QtWidgets.QTreeWidgetItem.__lt__(self, other) |
|||
|
|||
class SessionTreeController(object): |
|||
"""Controls a treeWidget, but does not subclass""" |
|||
|
|||
def __init__(self, mainWindow): |
|||
self.mainWindow = mainWindow |
|||
self.treeWidget = mainWindow.ui.session_tree |
|||
|
|||
self._cachedSessionDicts = None |
|||
self.mainWindow.ui.checkBoxNested.stateChanged.connect(self._reactSignal_nestedFlatChanged) |
|||
self._reactSignal_nestedFlatChanged(self.mainWindow.ui.checkBoxNested.isChecked()) #initial state |
|||
|
|||
#Configure the treewidget |
|||
#columns: name, path (relative from session dir), number of programs, disk size (links resolved?) |
|||
self.treeWidget.setColumnCount(4) |
|||
self.headerLabels = [ |
|||
QtCore.QCoreApplication.translate("SessionTree", "Name"), |
|||
QtCore.QCoreApplication.translate("SessionTree", "Last Save"), |
|||
QtCore.QCoreApplication.translate("SessionTree", "Clients"), |
|||
QtCore.QCoreApplication.translate("SessionTree", "Size"), |
|||
QtCore.QCoreApplication.translate("SessionTree", "Symlinks"), |
|||
QtCore.QCoreApplication.translate("SessionTree", "Path"), |
|||
] |
|||
self.treeWidget.setHeaderLabels(self.headerLabels) |
|||
self.treeWidget.setSortingEnabled(True) |
|||
self.treeWidget.setAlternatingRowColors(True) |
|||
self.treeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) |
|||
|
|||
#TODO: save sorting in user-wide qt application settings |
|||
#We remember sorting via signals layoutAboutToBeChanged and restore via layoutChanged |
|||
self.sortByColumn = 0 #by name |
|||
self.sortDescending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending |
|||
self.treeWidget.header().setSortIndicator(0,0) #Hack/Workaround. On startup it is not enough to set sorting. New items will be added in a random position. Maybe that is our async network adding. |
|||
#self.treeWidget.sortByColumn(self.sortByColumn, self.sortDescending) |
|||
|
|||
api.callbacks.sessionsChanged.append(self._reactCallback_sessionsChanged) |
|||
api.callbacks.sessionLocked.append(self._reactCallback_sessionLocked) |
|||
api.callbacks.sessionFileChanged.append(self._reactCallback_sessionFileChanged) |
|||
|
|||
|
|||
self.treeWidget.itemDoubleClicked.connect(self._reactSignal_itemDoubleClicked) |
|||
self.treeWidget.customContextMenuRequested.connect(self.contextMenu) |
|||
self.treeWidget.model().layoutAboutToBeChanged.connect(self._reactSignal_rememberSorting) |
|||
self.treeWidget.model().layoutChanged.connect(self._reactSignal_restoreSorting) |
|||
self.mainWindow.ui.button_new_session.clicked.connect(self._reactSignal_newSession) |
|||
self.mainWindow.ui.button_load_selected_session.clicked.connect(self._reactSignal_openSelected) |
|||
|
|||
|
|||
logger.info("Full View Session Chooser ready") |
|||
|
|||
def _reactCallback_sessionFileChanged(self, name:str, timestamp:str): |
|||
"""Timestamp of "last saved" changed""" |
|||
SessionItem.allItems[name].updateTimestamp(timestamp) |
|||
|
|||
def _reactCallback_sessionLocked(self, name:str, state:bool): |
|||
SessionItem.allItems[name].setLocked(state) |
|||
|
|||
def _reactCallback_sessionsChanged(self, sessionDicts:list): |
|||
"""Main callback for new, added, removed, moved sessions etc. |
|||
We also get this for every client change so we can update our numbers""" |
|||
self.treeWidget.clear() |
|||
|
|||
self._cachedSessionDicts = sessionDicts #in case we change the flat/nested mode. |
|||
|
|||
for sessionDict in sessionDicts: |
|||
self.addSessionItem(sessionDict) |
|||
|
|||
for i in range(len(self.headerLabels)): |
|||
self.treeWidget.resizeColumnToContents(i) |
|||
#Make the name column a few pixels wider |
|||
self.treeWidget.setColumnWidth(0, self.treeWidget.columnWidth(0) + 25) |
|||
self.treeWidget.sortByColumn(self.sortByColumn, self.sortDescending) |
|||
|
|||
|
|||
def _addItemNested(self, sessionDict:dict): |
|||
assert sessionDict, sessionDict |
|||
item = SessionItem(sessionDict) |
|||
if sessionDict["parents"]: |
|||
#These are already a hirarchy, sorted from parents to children |
|||
last = None #first is toplevel |
|||
for parentDir in sessionDict["parents"]: |
|||
alreadyExist = self.treeWidget.findItems(parentDir, QtCore.Qt.MatchExactly|QtCore.Qt.MatchRecursive, column=0) |
|||
if alreadyExist: |
|||
directoryItem = alreadyExist[0] |
|||
else: |
|||
directoryItem = DirectoryItem([parentDir]) |
|||
#directoryItem = QtWidgets.QTreeWidgetItem([parentDir], 0) #type 0 is qt default |
|||
|
|||
if last: |
|||
last.addChild(directoryItem) |
|||
else: |
|||
self.treeWidget.addTopLevelItem(directoryItem) |
|||
last = directoryItem |
|||
#After the loop: All subdirs built. Now add the item to the last one |
|||
last.addChild(item) |
|||
else: |
|||
self.treeWidget.addTopLevelItem(item) |
|||
|
|||
def _addItemFlat(self, sessionDict:dict): |
|||
assert sessionDict, sessionDict |
|||
sessionDict["name"] = sessionDict["nsmSessionName"] |
|||
item = SessionItem(sessionDict) |
|||
self.treeWidget.addTopLevelItem(item) |
|||
|
|||
def addSessionItem(self, sessionDict:dict): |
|||
if self.mode == "nested": |
|||
self._addItemNested(sessionDict) |
|||
elif self.mode == "flat": |
|||
self._addItemFlat(sessionDict) |
|||
else: |
|||
raise ValueError("Unknown SessionTree display mode") |
|||
|
|||
def deleteSessionItem(self, item:SessionItem): |
|||
"""Instruct the engine to fully delete a complete session item. |
|||
Will show a warning before.""" |
|||
text = QtCore.QCoreApplication.translate("SessionTree", "About to delete Session {}").format(item.sessionDict["nsmSessionName"]) |
|||
informativeText = QtCore.QCoreApplication.translate("SessionTree", "All files in the project directory will be irreversibly deleted.") |
|||
title = QtCore.QCoreApplication.translate("SessionTree", "All files in the project directory will be irreversibly deleted.") |
|||
title = QtCore.QCoreApplication.translate("SessionTree", "About to delete Session {}").format(item.sessionDict["nsmSessionName"]) |
|||
|
|||
box = QtWidgets.QMessageBox(self.treeWidget) |
|||
box.setIcon(box.Warning) |
|||
box.setText(text) |
|||
box.setWindowTitle(title) |
|||
box.setInformativeText(informativeText) |
|||
|
|||
keep = box.addButton(QtCore.QCoreApplication.translate("SessionTree", "Keep Session"), box.RejectRole) |
|||
box.addButton(QtCore.QCoreApplication.translate("SessionTree", "Delete!"), box.AcceptRole) |
|||
box.setDefaultButton(keep) |
|||
ret = box.exec() #0 or 1. Return values are NOT the button roles. |
|||
|
|||
if ret: #Delete |
|||
api.sessionDelete(item.sessionDict["nsmSessionName"]) |
|||
|
|||
|
|||
def contextMenu(self, qpoint): |
|||
item = self.treeWidget.itemAt(qpoint) |
|||
if not type(item) is SessionItem: |
|||
return |
|||
|
|||
menu = QtWidgets.QMenu() |
|||
|
|||
listOfLabelsAndFunctions = [ |
|||
(QtCore.QCoreApplication.translate("SessionTree", "Copy Session"), lambda: self._askForCopyAndCopy(item.sessionDict["nsmSessionName"])) |
|||
] |
|||
if item.isDisabled(): |
|||
listOfLabelsAndFunctions.append((QtCore.QCoreApplication.translate("SessionTree", "Force Lock Removal"), lambda: api.sessionForceLiftLock(item.sessionDict["nsmSessionName"]))) |
|||
else: |
|||
listOfLabelsAndFunctions.append((QtCore.QCoreApplication.translate("SessionTree", "Rename Session"), lambda: self._askForNameAndRenameSession(item.sessionDict["nsmSessionName"]))) |
|||
#Delete should be the bottom item. |
|||
listOfLabelsAndFunctions.append((QtCore.QCoreApplication.translate("SessionTree", "Delete Session"), lambda: self.deleteSessionItem(item))) |
|||
|
|||
|
|||
for text, function in listOfLabelsAndFunctions: |
|||
if function is None: |
|||
l = QtWidgets.QLabel(text) |
|||
l.setAlignment(QtCore.Qt.AlignCenter) |
|||
a = QtWidgets.QWidgetAction(menu) |
|||
a.setDefaultWidget(l) |
|||
menu.addAction(a) |
|||
else: |
|||
a = QtWidgets.QAction(text, menu) |
|||
menu.addAction(a) |
|||
a.triggered.connect(function) |
|||
|
|||
pos = QtGui.QCursor.pos() |
|||
pos.setY(pos.y() + 5) |
|||
menu.exec_(pos) |
|||
|
|||
|
|||
#GUI Signals |
|||
|
|||
def _reactSignal_itemDoubleClicked(self, item:QtWidgets.QTreeWidgetItem, column:int): |
|||
if not item.isDisabled() and type(item) is SessionItem: |
|||
api.sessionOpen(item.sessionDict["nsmSessionName"]) |
|||
|
|||
def _reactSignal_openSelected(self): |
|||
item = self.treeWidget.currentItem() |
|||
self._reactSignal_itemDoubleClicked(item, column=0) |
|||
|
|||
def _reactSignal_newSession(self): |
|||
widget = NewSessionDialog(parent=self.treeWidget, startwith="") |
|||
#widget = ProjectNameWidget(parent=self.treeWidget, startwith="") |
|||
if widget.result: |
|||
#result = {"name":str, "startclients":list} |
|||
api.sessionNew(widget.result["name"], widget.result["startclients"]) |
|||
|
|||
def _askForCopyAndCopy(self, nsmSessionName:str): |
|||
"""Called by button and context menu""" |
|||
widget = ProjectNameWidget(parent=self.treeWidget, startwith=nsmSessionName+"-copy") |
|||
if widget.result: |
|||
api.sessionCopy(nsmSessionName, widget.result) |
|||
|
|||
def _askForNameAndRenameSession(self, nsmSessionName:str): |
|||
"""Only for non-locked sessions. Context menu is only available if not locked.""" |
|||
widget = ProjectNameWidget(parent=self.treeWidget, startwith=nsmSessionName) |
|||
if widget.result and not widget.result == nsmSessionName: |
|||
api.sessionRename(nsmSessionName, widget.result) |
|||
|
|||
def _reactSignal_rememberSorting(self, *args): |
|||
self.sortByColumn = self.treeWidget.header().sortIndicatorSection() |
|||
self.sortDescending = self.treeWidget.header().sortIndicatorOrder() |
|||
|
|||
def _reactSignal_restoreSorting(self, *args): |
|||
self.treeWidget.sortByColumn(self.sortByColumn, self.sortDescending) |
|||
|
|||
def _reactSignal_nestedFlatChanged(self, checkStatus:bool): |
|||
"""#flat does not create directory items but changes the session name to dir/foo/bar""" |
|||
if checkStatus: |
|||
self.mode = "nested" |
|||
else: |
|||
self.mode = "flat" |
|||
#And rebuild the items without fetching new data. |
|||
if self._cachedSessionDicts: #not startup |
|||
self._reactCallback_sessionsChanged(self._cachedSessionDicts) |
|||
self.treeWidget.sortByColumn(self.sortByColumn, self.sortDescending) |
@ -0,0 +1,105 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ |
|||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) |
|||
|
|||
API documentation: http://non.tuxfamily.org/nsm/API.html |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), |
|||
more specifically its template base application. |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
#Engine |
|||
import engine.api as api #This loads the engine and starts a session. |
|||
|
|||
class SystemTray(QtWidgets.QSystemTrayIcon): |
|||
|
|||
def __init__(self, mainWindow): |
|||
super().__init__(QtGui.QIcon("icon.png")) |
|||
self.mainWindow = mainWindow |
|||
self.available = self.isSystemTrayAvailable() |
|||
self.show() |
|||
#self.showMessage("Title", "Helllo!", QtWidgets.QSystemTrayIcon.Information) #title, message, icon, timeout. #has messageClicked() signal. |
|||
#Don't build the context menu here. The engine is not ready to provide us with session information. Let the callbacks do it. |
|||
|
|||
#self.messageClicked.connect(self._reactSignal_messageClicked) |
|||
self.activated.connect(self._reactSignal_activated) |
|||
|
|||
#Connect to api callbacks to rebuild context menu when session changes |
|||
api.callbacks.sessionClosed.append(self.buildContextMenu) |
|||
api.callbacks.sessionOpenReady.append(self.buildContextMenu) |
|||
api.callbacks.sessionsChanged.append(self.buildContextMenu) |
|||
|
|||
def buildContextMenu(self, *args): |
|||
"""In a function for readability. |
|||
It gets rebuild everytime a session is opened or closed or the session list changed |
|||
""" |
|||
menu = QtWidgets.QMenu() |
|||
def _add(text, function): |
|||
a = QtWidgets.QAction(text, menu) |
|||
menu.addAction(a) |
|||
a.triggered.connect(function) |
|||
|
|||
nsmSessionName = api.currentSession() |
|||
|
|||
|
|||
_add(QtCore.QCoreApplication.translate("TrayIcon", "Hide/Show Argodejo"), self.mainWindow.toggleVisible) |
|||
menu.addSeparator() |
|||
|
|||
#Add other pre-defined actions |
|||
if nsmSessionName: |
|||
menu.addAction(self.mainWindow.ui.actionShow_All_Clients) |
|||
menu.addAction(self.mainWindow.ui.actionHide_All_Clients) |
|||
menu.addSeparator() |
|||
|
|||
if nsmSessionName: #We are in a loaded session |
|||
_add(QtCore.QCoreApplication.translate("TrayIcon", "Save && Close {}".format(nsmSessionName)), api.sessionClose) |
|||
_add(QtCore.QCoreApplication.translate("TrayIcon", "Abort {}".format(nsmSessionName)), api.sessionAbort) |
|||
menu.addSeparator() |
|||
_add(QtCore.QCoreApplication.translate("TrayIcon", "Save && Quit Argodejo"), self.mainWindow.closeAndQuit) |
|||
_add(QtCore.QCoreApplication.translate("TrayIcon", "Abort && Quit Argodejo"), self.mainWindow.abortAndQuit) |
|||
menu.addSeparator() |
|||
else: |
|||
for recentName in self.mainWindow.recentlyOpenedSessions.get(): |
|||
_add(f"Session: {recentName}", lambda: api.sessionOpen(recentName)) |
|||
|
|||
_add(QtCore.QCoreApplication.translate("TrayIcon", "Quit "), self.mainWindow.menuRealQuit) |
|||
|
|||
self.setContextMenu(menu) |
|||
|
|||
def _reactSignal_activated(self, qActivationReason): |
|||
""" |
|||
QtWidgets.QSystemTrayIcon.Unknown |
|||
QtWidgets.QSystemTrayIcon.Context |
|||
QtWidgets.QSystemTrayIcon.DoubleClick |
|||
QtWidgets.QSystemTrayIcon.Trigger |
|||
QtWidgets.QSystemTrayIcon.MiddleClick |
|||
""" |
|||
logger.info(f"System tray activated with reason {qActivationReason}") |
|||
if qActivationReason == QtWidgets.QSystemTrayIcon.Trigger: |
|||
self.mainWindow.toggleVisible() |
|||
|
|||
#def _reactSignal_messageClicked(self): |
|||
# """this signal is emitted when the message displayed using |
|||
# showMessage() was clicked by the user.""" |
|||
# print ("clicky") |
@ -0,0 +1,76 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ) |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger(__name__); logger.info("import") |
|||
|
|||
#Standard Library |
|||
|
|||
|
|||
#Third Party |
|||
from PyQt5 import QtCore, QtGui, QtWidgets |
|||
|
|||
|
|||
class WaitThread(QtCore.QThread): |
|||
def __init__(self, longRunningFunction): |
|||
self.longRunningFunction = longRunningFunction |
|||
QtCore.QThread.__init__(self) |
|||
|
|||
def __del__(self): |
|||
self.wait() |
|||
|
|||
def run(self): |
|||
self.longRunningFunction() |
|||
|
|||
|
|||
class WaitDialog(QtWidgets.QMessageBox): |
|||
"""An information box that closes itself once a task is done. |
|||
Executes and shows on construction""" |
|||
|
|||
def __init__(self, mainWindow, title, text, informativeText, longRunningFunction): |
|||
#text = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit but session {} still open").format(nsmSessionName) |
|||
#informativeText = QtCore.QCoreApplication.translate("AskBeforeQuit", "Do you want to save?") |
|||
#title = QtCore.QCoreApplication.translate("AskBeforeQuit", "About to quit") |
|||
|
|||
super().__init__() |
|||
self.mainWindow = mainWindow |
|||
self.setWindowFlag(QtCore.Qt.Popup, True) |
|||
self.setIcon(self.Information) |
|||
self.setText(text) |
|||
self.setWindowTitle(title) |
|||
self.setInformativeText(informativeText) |
|||
self.setStandardButtons(QtWidgets.QMessageBox.NoButton) #no buttons |
|||
|
|||
wt = WaitThread(longRunningFunction) |
|||
wt.finished.connect(self.realClose) |
|||
wt.start() |
|||
|
|||
self.exec() |
|||
|
|||
def realClose(self): |
|||
self.closeEvent = QtWidgets.QMessageBox.closeEvent |
|||
self.done(True) |
|||
|
|||
def keyPressEvent(self, event): |
|||
event.ignore() |
|||
|
|||
def closeEvent(self, event): |
|||
event.ignore() |
|||
|
@ -0,0 +1,292 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), |
|||
more specifically its template base application. |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
import logging; logger = logging.getLogger("nsm-data"); logger.info("import") |
|||
|
|||
import json |
|||
import pathlib |
|||
from time import sleep |
|||
from sys import exit as sysexit |
|||
|
|||
from nsmclient import NSMClient |
|||
|
|||
URL="https://www.laborejo.org/argodejo/nsm-data" |
|||
VERSION= 1.0 |
|||
HARD_LIMIT = 512 # no single message longer than this |
|||
|
|||
def chunkstring(string): |
|||
return [string[0+i:HARD_LIMIT+i] for i in range(0, len(string), HARD_LIMIT)] |
|||
|
|||
class DataClient(object): |
|||
""" |
|||
Keys are strings, |
|||
While nsmd OSC support int, str and float we use json exclusively. |
|||
We expect a json string and will parse it here. |
|||
|
|||
All message consist of two arguments maximum: a key and, if a create-function, a json string. |
|||
|
|||
Rule: all client-keys are send as strings, even in replies. All client-values are send as |
|||
json-string, even if originally just a string. |
|||
|
|||
Description is a multi-part message, a string. |
|||
|
|||
DataClient will register itself as Data-Storage. All other communication is done via osc. |
|||
In theory every application can read and write us (like a book!) |
|||
|
|||
We listen to OSC paths and reply to the sender, which must give its address explicitly. |
|||
/argodejo/datastorage/readall s:request-host i:request-port #Request all data |
|||
/argodejo/datastorage/read s:key s:request-host i:request-port #Request one value |
|||
|
|||
The write functions have no reply. They will print out to stdout/err but not send an error |
|||
message back. |
|||
/argodejo/datastorage/create s:key any:value #Write/Create one value |
|||
/argodejo/datastorage/update s:kecy any:value #Update a value, but only if it exists |
|||
/argodejo/datastorage/delete s:key #Remove a key/value completely |
|||
""" |
|||
|
|||
def __init__(self): |
|||
|
|||
self.data = None #Dict. created in openOrNewCallbackFunction, saved as json |
|||
self.absoluteJsonFilePath = None #pathlib.Path set by openOrNewCallbackFunction |
|||
|
|||
self._descriptionStringArray = {"identifier":None} #int:str |
|||
self._descriptionId = None |
|||
|
|||
self.nsmClient = NSMClient(prettyName = "Data-Storage", #will raise an error and exit if this example is not run from NSM. |
|||
saveCallback = self.saveCallbackFunction, |
|||
openOrNewCallback = self.openOrNewCallbackFunction, |
|||
supportsSaveStatus = True, # Change this to True if your program announces it's save status to NSM |
|||
exitProgramCallback = self.exitCallbackFunction, |
|||
broadcastCallback = None, |
|||
hideGUICallback = None, #replace with your hiding function. You need to answer in your function with nsmClient.announceGuiVisibility(False) |
|||
showGUICallback = None, #replace with your showing function. You need to answer in your function with nsmClient.announceGuiVisibility(True) |
|||
sessionIsLoadedCallback = self.sessionIsLoadedCallback, #no parametersd |
|||
loggingLevel = "error", #"info" for development or debugging, "error" for production. default is error. |
|||
) |
|||
|
|||
#Add custom callbacks. They all receive _IncomingMessage(data) |
|||
self.nsmClient.reactions["/argodejo/datastorage/setclientoverridename"] = self.setClientOverrideName |
|||
self.nsmClient.reactions["/argodejo/datastorage/getclientoverridename"] = self.getClientOverrideName |
|||
self.nsmClient.reactions["/argodejo/datastorage/getall"] = self.getAll |
|||
self.nsmClient.reactions["/argodejo/datastorage/getdescription"] = self.getDescription |
|||
self.nsmClient.reactions["/argodejo/datastorage/setdescription"] = self.setDescription |
|||
#self.nsmClient.reactions["/argodejo/datastorage/read"] = self.reactRead |
|||
#self.nsmClient.reactions["/argodejo/datastorage/readall"] = self.reactReadAll |
|||
#self.nsmClient.reactions["/argodejo/datastorage/create"] = self.reactCreate |
|||
#self.nsmClient.reactions["/argodejo/datastorage/update"] = self.reactUpdate |
|||
#self.nsmClient.reactions["/argodejo/datastorage/delete"] = self.reactDelete |
|||
|
|||
#NsmClients only returns from init when it has a connection, and on top (for us) when session is ready. It is safe to announce now. |
|||
self.nsmClient.broadcast("/argodejo/datastorage/announce", [self.nsmClient.ourClientId, HARD_LIMIT, self.nsmClient.ourOscUrl]) |
|||
|
|||
while True: |
|||
self.nsmClient.reactToMessage() |
|||
sleep(0.05) #20fps update cycle |
|||
|
|||
def getAll(self, msg): |
|||
"""A complete data dumb, intended to use once after startup. |
|||
Will split into multiple reply messages, if needed""" |
|||
senderHost, senderPort = msg.params |
|||
path = "/argodejo/datastorage/reply/getall" |
|||
encoded = json.dumps(self.data) |
|||
chunks = chunkstring(encoded) |
|||
l = len(chunks) |
|||
for index, chunk in enumerate(chunks): |
|||
listOfParameters = [index+0, l-1, chunk] |
|||
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) |
|||
|
|||
def getDescription(self, msg)->str: |
|||
"""Returns a normal string, not json""" |
|||
senderHost, senderPort = msg.params |
|||
path = "/argodejo/datastorage/reply/getdescription" |
|||
chunks = chunkstring(self.data["description"]) |
|||
l = len(chunks) |
|||
for index, chunk in enumerate(chunks): |
|||
listOfParameters = [index+0, l-1, chunk] |
|||
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) |
|||
|
|||
def setDescription(self, msg): |
|||
""" |
|||
Answers with descriptionId and index when data was received and saved. |
|||
|
|||
The GUI needs to buffer this a bit. Don't send every char as single message. |
|||
|
|||
This is for multi-part messages |
|||
Index is 0 based, |
|||
chunk is part of a simple string, not json. |
|||
|
|||
The descriptionId:int indicates the message the chunks belong to. |
|||
If we see a new one we reset our storage. |
|||
""" |
|||
descriptionId, index, chunk, senderHost, senderPort = msg.params #str, int, str, str, int |
|||
if not self._descriptionId == descriptionId: |
|||
self._descriptionId = descriptionId |
|||
self._descriptionStringArray.clear() |
|||
self._descriptionStringArray[index] = chunk |
|||
buildString = "".join([v for k,v in sorted(self._descriptionStringArray.items())]) |
|||
self.data["description"] = buildString |
|||
self.nsmClient.announceSaveStatus(False) |
|||
|
|||
def getClientOverrideName(self, msg): |
|||
"""Answers with empty string if clientId does not exist or has not data. This is a signal |
|||
for the GUI/host to use the original name!""" |
|||
clientId, senderHost, senderPort = msg.params |
|||
path = "/argodejo/datastorage/reply/getclient" |
|||
if clientId in self.data["clientOverrideNames"]: |
|||
name = self.data["clientOverrideNames"][clientId] |
|||
else: |
|||
logger.info(f"We were instructed to read client {clientId}, but it does not exist") |
|||
name = "" |
|||
listOfParameters = [clientId, json.dumps(name)] |
|||
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) |
|||
|
|||
def setClientOverrideName(self, msg): |
|||
"""We accept empty string as a name to remove the name override. |
|||
""" |
|||
clientId, jsonValue = msg.params |
|||
name = json.loads(jsonValue)[:HARD_LIMIT] |
|||
if name: |
|||
self.data["clientOverrideNames"][clientId] = name |
|||
else: |
|||
#It is possible that a client not present in our storage will send an empty string. Protect. |
|||
if clientId in self.data["clientOverrideNames"]: |
|||
del self.data["clientOverrideNames"][clientId] |
|||
self.nsmClient.announceSaveStatus(False) |
|||
|
|||
#Generic Functions. Not in use and not ready. |
|||
#Callback Reactions to OSC. They all receive _IncomingMessage(data) |
|||
def reactReadAll(self, msg): |
|||
senderHost, senderPort = msg.params |
|||
path = "/argodejo/datastorage/reply/readall" |
|||
encoded = json.dumps("") |
|||
chunks = chunkstring(encoded, 512) |
|||
l = len(chunks) |
|||
for index, chunk in enumerate(chunks): |
|||
listOfParameters = [index+0, l-1, chunk] |
|||
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) |
|||
|
|||
def reactRead(self, msg): |
|||
key, senderHost, senderPort = msg.params |
|||
if key in self.data: |
|||
path = "/argodejo/datastorage/reply/read" |
|||
listOfParameters = [key, json.dumps(self.data[key])] |
|||
self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) |
|||
else: |
|||
logger.warning(f"We were instructed to read key {key}, but it does not exist") |
|||
|
|||
def reactCreate(self, msg): |
|||
key, jsonValue = msg.params |
|||
value = json.loads(jsonValue) |
|||
self.data[key] = value |
|||
self.nsmClient.announceSaveStatus(False) |
|||
|
|||
def reactUpdate(self, msg): |
|||
key, jsonValue = msg.params |
|||
value = json.loads(jsonValue) |
|||
if key in self.data: |
|||
self.data[key] = value |
|||
self.nsmClient.announceSaveStatus(False) |
|||
else: |
|||
logger.warning(f"We were instructed to update key {key} with value {value}, but it does not exist") |
|||
|
|||
def reactDelete(self, msg): |
|||
key = msg.params[0] |
|||
if key in self.data: |
|||
del self.data[key] |
|||
self.nsmClient.announceSaveStatus(False) |
|||
else: |
|||
logger.warning(f"We were instructed to delete key {key}, but it does not exist") |
|||
|
|||
#NSM Callbacks and File Handling |
|||
|
|||
def saveCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): |
|||
result = self.data |
|||
result["origin"] = URL |
|||
result["version"] = VERSION |
|||
jsonData = json.dumps(result, indent=2) |
|||
|
|||
try: |
|||
with open(self.absoluteJsonFilePath, "w", encoding="utf-8") as f: |
|||
f.write(jsonData) |
|||
except Exception as e: |
|||
logging.error("Will not load or save because: " + e.__repr__()) |
|||
return self.absoluteJsonFilePath |
|||
#nsmclient.py will send save status clean |
|||
|
|||
def openOrNewCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): |
|||
self.absoluteJsonFilePath = pathlib.Path(ourPath) |
|||
try: |
|||
self.data = self.openFromJson(self.absoluteJsonFilePath) |
|||
except FileNotFoundError: |
|||
self.data = None #This makes debugging output nicer. If we init Data() here all errors will be presented as follow-up error "while handling exception FileNotFoundError". |
|||
except (NotADirectoryError, PermissionError) as e: |
|||
self.data = None |
|||
logger.error("Will not load or save because: " + e.__repr__()) |
|||
|
|||
if not self.data: |
|||
self.data = {"clientOverrideNames":{}, "description":""} |
|||
logger.info("New/Open complete") |
|||
#TODO: send data |
|||
|
|||
def openFromJson(self, absoluteJsonFilePath): |
|||
with open(absoluteJsonFilePath, "r", encoding="utf-8") as f: |
|||
try: |
|||
text = f.read() |
|||
result = json.loads(text) |
|||
except Exception as error: |
|||
result = None |
|||
logger.error(error) |
|||
|
|||
if result and "version" in result and "origin" in result and result["origin"] == URL: |
|||
if result["version"] >= VERSION: |
|||
assert type(result) is dict, (result, type(result)) |
|||
logger.info("Loading file from json complete") |
|||
return result |
|||
else: |
|||
logger.error(f"""{absoluteJsonFilePath} was saved with {result["version"]} but we need {VERSION}""") |
|||
sysexit() |
|||
else: |
|||
logger.error(f"""Error. {absoluteJsonFilePath} not loaded. Not a sane argodejo/nsm-data file in json format""") |
|||
sysexit() |
|||
|
|||
|
|||
def sessionIsLoadedCallback(self): |
|||
"""At one point I thought we could send our data when session is ready, so the GUI actually |
|||
has clients to rename. However, that turned out impossible or impractical. |
|||
Instead the GUI now just fails if nameOverrides that we send are not available yet and tries |
|||
again later. |
|||
|
|||
Leave that in for documentation. |
|||
""" |
|||
pass |
|||
|
|||
|
|||
|
|||
#def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments): |
|||
# print (__file__, "broadcast") |
|||
|
|||
def exitCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): |
|||
sysexit(0) |
|||
|
|||
if __name__ == '__main__': |
|||
"""Creating an instance starts the client and does not return""" |
|||
DataClient() |
@ -0,0 +1 @@ |
|||
/home/nils/clones/pynsm2/nsmclient.py |
@ -0,0 +1,216 @@ |
|||
#! /usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
""" |
|||
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net ) |
|||
|
|||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ |
|||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) |
|||
|
|||
API documentation: http://non.tuxfamily.org/nsm/API.html |
|||
|
|||
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), |
|||
more specifically its template base application. |
|||
|
|||
The Template Base Application is free software: you can redistribute it and/or modify |
|||
it under the terms of the GNU General Public License as published by |
|||
the Free Software Foundation, either version 3 of the License, or |
|||
(at your option) any later version. |
|||
|
|||
This program is distributed in the hope that it will be useful, |
|||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
GNU General Public License for more details. |
|||
|
|||
You should have received a copy of the GNU General Public License |
|||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|||
""" |
|||
|
|||
#Standard Library |
|||
import logging |
|||
import threading |
|||
from time import sleep |
|||
import cmd |
|||
from pprint import pprint |
|||
import sys |
|||
|
|||
from nsmservercontrol import NsmServerControl |
|||
|
|||
class NSMCmd(cmd.Cmd): |
|||
intro = "Welcome to the NSM commandline tester. Type help or ? to list commands.\nThere is no prompt, just type.\n" |
|||
prompt = "" |
|||
file = None #? |
|||
|
|||
lastClientId = None |
|||
|
|||
def _clientId(self, arg): |
|||
if arg: |
|||
NSMCmd.lastClientId = arg |
|||
return arg |
|||
elif NSMCmd.lastClientId: |
|||
return NSMCmd.lastClientId |
|||
else: |
|||
return arg |
|||
|
|||
def do_announce(self, arg): |
|||
"""Announce ourselves as GUI. This is sent automatically at program start, |
|||
but nsmd only remembers the last GUI that announces. Calling announce takes back control""" |
|||
nsmServerControl.gui_announce() |
|||
|
|||
def do_ping(self, arg): |
|||
"""Ping the server""" |
|||
nsmServerControl.ping() |
|||
|
|||
def do_broadcast(self, arg): |
|||
"""Send a message too all clients in the current session, except ourselves""" |
|||
args = arg.split() |
|||
path = args[0] |
|||
arguments = args[1:] |
|||
nsmServerControl.broadcast(path, arguments) |
|||
|
|||
def do_listSessions(self, arg): |
|||
"""Request a list of projects, or sessions, from the server.""" |
|||
nsmServerControl.list() |
|||
print ("Now call 'status'") |
|||
#This won't work because when we get back control from list the data is not here yet. |
|||
#print(nsmServerControl.internalState["sessions"]) |
|||
|
|||
def do_saveSessions(self, arg): |
|||
"""Save currently open session""" |
|||
nsmServerControl.save() |
|||
|
|||
def do_closeSession(self, arg): |
|||
"""Close currently open session""" |
|||
nsmServerControl.close() |
|||
|
|||
def do_abortSession(self, arg): |
|||
"""Close without saving!""" |
|||
nsmServerControl.abort() |
|||
|
|||
def do_liftLockedSession(self, arg): |
|||
"""Remove the .lock file from a session. Use with caution. Intended for crash recovery. |
|||
Does nothing if not locked.""" |
|||
nsmServerControl.forceLiftLock(arg) |
|||
|
|||
def do_quitServer(self, arg): |
|||
"""Gracefully shut down the server, which will save. Then exit to OS.""" |
|||
#nsmServerControl.quit() #We don't need that. Called by @atexit |
|||
quit() |
|||
|
|||
def do_addClient(self, arg): |
|||
"""Add one client to current session. Executable must be in $PATH""" |
|||
nsmServerControl.clientAdd(arg) |
|||
|
|||
def do_openSession(self, arg): |
|||
"""Open an existing session with a name as shown by the list command""" |
|||
nsmServerControl.open(arg) |
|||
|
|||
def do_newSession(self, arg): |
|||
"""Saves the current session and creates a new session.""" |
|||
nsmServerControl.new(arg) |
|||
|
|||
def do_duplicateSession(self, arg): |
|||
"""Saves the current session, closes it and opens a copy of it with the given name.""" |
|||
nsmServerControl.duplicate(arg) |
|||
|
|||
def do_copySession(self, arg): |
|||
"""Copy a session with our internal methods. Does not use nsm duplicate and can operate |
|||
at any time at any session, locked or not.""" |
|||
nsmSessionName, newName = arg.split() |
|||
nsmServerControl.copySession(nsmSessionName, newName) |
|||
|
|||
def do_hideClient(self, arg): |
|||
"""Instruct a client to hide its GUI. Will do nothing if client does not support it""" |
|||
arg = self._clientId(arg) |
|||
nsmServerControl.clientHide(arg) |
|||
|
|||
def do_showClient(self, arg): |
|||
"""Instruct a client to show its GUI again. Will do nothing if client does not support it""" |
|||
arg = self._clientId(arg) |
|||
nsmServerControl.clientShow(arg) |
|||
|
|||
def do_removeClient(self, arg): |
|||
"""Remove a stopped client from a running session""" |
|||
arg = self._clientId(arg) |
|||
nsmServerControl.clientRemove(arg) |
|||
|
|||
def do_stopClient(self, arg): |
|||
"""Stop a client in a running session""" |
|||
arg = self._clientId(arg) |
|||
nsmServerControl.clientStop(arg) |
|||
|
|||
def do_resumeClient(self, arg): |
|||
"""Resume a previously stopped client""" |
|||
arg = self._clientId(arg) |
|||
nsmServerControl.clientResume(arg) |
|||
|
|||
def do_saveClient(self, arg): |
|||
"""instruct a specific client to save""" |
|||
arg = self._clientId(arg) |
|||
nsmServerControl.clientSave(arg) |
|||
|
|||
def do_allClientsShow(self, arg): |
|||
"""Call clientShow for all clients""" |
|||
nsmServerControl.allClientsShow() |
|||
|
|||
def do_allClientsHide(self, arg): |
|||
"""Call clientHide for all clients""" |
|||
nsmServerControl.allClientsHide() |
|||
|
|||
def do_deleteSession(self, arg): |
|||
"""Delete a session directory. This is a destructive operation without undo""" |
|||
nsmServerControl.deleteSession(arg) |
|||
|
|||
def do_renameSession(self, arg): |
|||
"""Rename a non-open session. Arguments: nsmSessionName (from listSessions) newName""" |
|||
nsmSessionName, newName = arg.split() |
|||
nsmServerControl.renameSession(nsmSessionName, newName) |
|||
|
|||
#Internal, not requests for nsm |
|||
def do_status(self, arg): |
|||
"""show internal status. Does not query nsm for any updates or live data. |
|||
Therefore can be out of date, e.g. after program start there are no listed |
|||
sessions. call listSessions first.""" |
|||
pprint(nsmServerControl.internalState) |
|||
|
|||
def do_loggingInfo(self, arg): |
|||
"""Set logging level to very verbose""" |
|||
logging.basicConfig(level=logging.INFO) |
|||
|
|||
def do_loggingWarning(self, arg): |
|||
"""Set logging level to warnings and errors""" |
|||
logging.basicConfig(level=logging.WARNING) |
|||
|
|||
def do_loggingError(self, arg): |
|||
"""Set logging level to only errors""" |
|||
logging.basicConfig(level=logging.ERROR) |
|||
|
|||
#def default(self, arg): |
|||
# nsmServerControl.send(arg) |
|||
|
|||
|
|||
def run_receivingServer(): |
|||
"""Run forever |
|||
http://sebastiandahlgren.se/2014/06/27/running-a-method-as-a-background-thread-in-python/ |
|||
""" |
|||
while True: |
|||
nsmServerControl.process() |
|||
sleep(0.001) |
|||
|
|||
def nothing(*args): |
|||
pass |
|||
|
|||
if __name__ == '__main__': |
|||
logging.basicConfig(level=logging.INFO) #development |
|||
|
|||
try: |
|||
URL = sys.argv[1] |
|||
print ("Start with URL", sys.argv[1]) |
|||
except: |
|||
URL = None |
|||
|
|||
nsmServerControl = NsmServerControl(useCallbacks=True,sessionOpenReadyHook=nothing,sessionOpenLoadingHook=nothing,sessionClosedHook=nothing,clientStatusHook=nothing,singleInstanceActivateWindowHook=nothing,dataClientHook=nothing, parameterNsmOSCUrl=URL) |
|||
thread = threading.Thread(target=run_receivingServer, args=()) |
|||
thread.daemon = True # Daemonize thread |
|||
thread.start() # Start the execution |
|||
|
|||
NSMCmd().cmdloop() |
@ -0,0 +1 @@ |
|||
../engine/nsmservercontrol.py |
Loading…
Reference in new issue