#! /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 ) .
This 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
logger . info ( f " PyQt Version: { QtCore . PYQT_VERSION_STR } " )
#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
from . resources import *
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 ] , " :/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 ) - > list :
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 . ui . mainPageSwitcher . setCurrentIndex ( 0 ) #1 is messageLabel 0 is the tab widget
#TODO: Hide information tab until the feature is ready
self . ui . tabbyCat . removeTab ( 2 )
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 ( )
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
logger . info ( " Trying to restore cached program database " )
settings = QtCore . QSettings ( " LaborejoSoftwareSuite " , METADATA [ " shortName " ] )
if settings . contains ( " programDatabase " ) :
listOfDicts = settings . value ( " programDatabase " , type = list )
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.
logger . info ( " First run. Instructing engine to build program database " )
QtCore . QTimer . singleShot ( 0 , self . updateProgramDatabase ) #includes self._updateGUIWithCachedPrograms()
logger . info ( " Deciding if we run as tray-icon or window " )
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.
logger . info ( " Ready for user input. Exec_ Qt. " )
qtApp . exec_ ( )
#No code after exec_ except atexit
def tabtest ( self ) :
import subprocess
from time import sleep
#xdotool search --name xeyes
#xdotool search --pid 12345
subprocess . Popen ( [ " patchage " ] , shell = True , stdin = None , stdout = None , stderr = None , close_fds = True ) #parameters are for not waiting
sleep ( 1 )
result = subprocess . run ( [ " xdotool " , " search " , " --name " , " patchage " ] , stdout = subprocess . PIPE ) . stdout . decode ( ' utf-8 ' )
if " \n " in result :
windowID = int ( result . split ( " \n " ) [ 0 ] )
else :
windowID = int ( result )
window = QtGui . QWindow . fromWinId ( int ( windowID ) )
window . setFlags ( QtCore . Qt . FramelessWindowHint )
widget = QtWidgets . QWidget . createWindowContainer ( window )
self . ui . tabbyCat . addTab ( widget , " Patchage " )
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 ( " mainWindow " , " 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 ( " mainWindow " , " Updating Program Database. \n Thank you for your patience. " )
settings = QtCore . QSettings ( " LaborejoSoftwareSuite " , METADATA [ " shortName " ] )
settings . remove ( " programDatabase " )
logger . info ( " Asking api to getSystemPrograms while waiting " )
diag = WaitDialog ( self , text , api . buildSystemPrograms ) #save in local var to keep alive
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. """
logger . info ( " Restoring window settings, geometry and recently opened session " )
settings = QtCore . QSettings ( " LaborejoSoftwareSuite " , METADATA [ " shortName " ] )
actions = {
" geometry " : self . restoreGeometry ,
" windowState " : self . restoreState ,
" recentlyOpenedSessions " : self . recentlyOpenedSessions . load ,
" tab " : lambda i : self . ui . tabbyCat . setCurrentIndex ( int ( i ) ) ,
}
types = {
" recentlyOpenedSessions " : list ,
" tab " : int ,
}
for key in settings . allKeys ( ) :
if key in actions : #if not it doesn't matter. this is all uncritical.
if key in types :
actions [ key ] ( settings . value ( key , type = types [ key ] ) )
else :
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 )