diff --git a/vasl_templates/main.py b/vasl_templates/main.py index f8af00f..89733cf 100755 --- a/vasl_templates/main.py +++ b/vasl_templates/main.py @@ -9,16 +9,18 @@ import traceback import logging import urllib.request +import PyQt5.QtWebEngineWidgets from PyQt5.QtWidgets import QApplication from PyQt5.QtCore import Qt, QSettings, QDir import PyQt5.QtCore import click -from vasl_templates.main_window import MainWindow from vasl_templates.webapp import app as webapp from vasl_templates.webapp import load_debug_config from vasl_templates.webapp import main as webapp_main, snippets as webapp_snippets +app_settings = None + # --------------------------------------------------------------------- _QT_LOGGING_LEVELS = { @@ -44,7 +46,7 @@ def qtMessageHandler( msg_type, context, msg ):# pylint: disable=unused-argument @click.option( "--default-scenario", help="Default scenario settings." ) @click.option( "--remote-debugging", help="Chrome DevTools port number." ) @click.option( "--debug", help="Debug config file." ) -def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: disable=too-many-locals +def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: disable=too-many-locals,too-many-branches """Main entry point for the application.""" # configure the default template pack @@ -70,10 +72,12 @@ def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: d os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = remote_debugging # load the application settings - fname = "vasl-templates.ini" if sys.platform == "win32" else ".vasl-templates.conf" - if not os.path.isfile( fname ) : - fname = os.path.join( QDir.homePath(), fname ) - settings = QSettings( fname, QSettings.IniFormat ) + app_settings_fname = "vasl-templates.ini" if sys.platform == "win32" else ".vasl-templates.conf" + if not os.path.isfile( app_settings_fname ) : + app_settings_fname = os.path.join( QDir.homePath(), app_settings_fname ) + # FUDGE! Declaring app_settings as global here doesn't work on Windows (?!), we have to do this weird import :-/ + import vasl_templates.main #pylint: disable=import-self + vasl_templates.main.app_settings = QSettings( app_settings_fname, QSettings.IniFormat ) # install the debug config file if debug: @@ -82,6 +86,22 @@ def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: d # connect PyQt's logging to Python logging PyQt5.QtCore.qInstallMessageHandler( qtMessageHandler ) + # FUDGE! We need to do this before showing any UI elements e.g. an error message box. + app = QApplication( sys.argv ) + + # install the server settings + try: + from vasl_templates.server_settings import install_server_settings #pylint: disable=cyclic-import + install_server_settings() + except Exception as ex: #pylint: disable=broad-except + from vasl_templates.main_window import MainWindow #pylint: disable=cyclic-import + MainWindow.showErrorMsg( + "Couldn't install the server settings:\n {}\n\n" + "Please correct them in the \"Server settings\" dialog, or in the config file:\n {}".format( + ex, app_settings_fname + ) + ) + # disable the Flask "do not use in a production environment" warning import flask.cli flask.cli.show_server_banner = lambda *args: None @@ -125,9 +145,9 @@ def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: d disable_browser = webapp.config.get( "DISABLE_WEBENGINEVIEW" ) # run the application - app = QApplication( sys.argv ) url = "http://localhost:{}".format( port ) - main_window = MainWindow( settings, url, disable_browser ) + from vasl_templates.main_window import MainWindow #pylint: disable=cyclic-import + main_window = MainWindow( url, disable_browser ) main_window.show() ret_code = app.exec_() diff --git a/vasl_templates/main_window.py b/vasl_templates/main_window.py index f1dfe01..0a3288f 100644 --- a/vasl_templates/main_window.py +++ b/vasl_templates/main_window.py @@ -6,13 +6,14 @@ import json import io import logging -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QMessageBox +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QMenuBar, QAction, QLabel, QMessageBox from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage from PyQt5.QtWebChannel import QWebChannel from PyQt5.QtGui import QDesktopServices, QIcon from PyQt5.QtCore import Qt, QUrl, pyqtSlot from vasl_templates.webapp.config.constants import APP_NAME +from vasl_templates.main import app_settings from vasl_templates.web_channel import WebChannelHandler from vasl_templates.utils import log_exceptions @@ -42,11 +43,12 @@ class AppWebPage( QWebEnginePage ): class MainWindow( QWidget ): """Main application window.""" - def __init__( self, settings, url, disable_browser ): + instance = None + + def __init__( self, url, disable_browser ): # initialize super().__init__() - self.settings = settings self._view = None self._is_closing = False @@ -56,12 +58,23 @@ class MainWindow( QWidget ): os.path.join( os.path.split(__file__)[0], "webapp/static/images/app.ico" ) ) ) + # create the menu + menu_bar = QMenuBar( self ) + file_menu = menu_bar.addMenu( "&File" ) + def add_action( caption, handler ): + """Add a menu action.""" + action = QAction( caption, self ) + action.triggered.connect( handler ) + file_menu.addAction( action ) + add_action( "&Settings", self.on_settings ) + add_action( "E&xit", self.on_exit ) + # set the window geometry if disable_browser: self.setFixedSize( 300, 100 ) else: # restore it from the previous session - val = self.settings.value( "MainWindow/geometry" ) + val = app_settings.value( "MainWindow/geometry" ) if val : self.restoreGeometry( val ) else : @@ -69,12 +82,13 @@ class MainWindow( QWidget ): self.setMinimumSize( 800, 500 ) # initialize the layout + layout = QVBoxLayout( self ) + layout.addWidget( menu_bar ) # FUDGE! We offer the option to disable the QWebEngineView since getting it to run # under Windows (especially older versions) is unreliable (since it uses OpenGL). # By disabling it, the program will at least start (in particular, the webapp server), # and non-technical users can then open an external browser and connect to the webapp # that way. Sigh... - layout = QVBoxLayout( self ) if not disable_browser: # initialize the web view @@ -115,6 +129,10 @@ class MainWindow( QWidget ): label.setOpenExternalLinks( True ) layout.addWidget( label ) + # register the instance + assert MainWindow.instance is None + MainWindow.instance = self + def closeEvent( self, evt ) : """Handle requests to close the window (i.e. exit the application).""" @@ -125,7 +143,7 @@ class MainWindow( QWidget ): def close_window(): """Close the main window.""" if self._view: - self.settings.setValue( "MainWindow/geometry" , self.saveGeometry() ) + app_settings.setValue( "MainWindow/geometry", self.saveGeometry() ) self.close() # check if the scenario is dirty @@ -137,7 +155,7 @@ class MainWindow( QWidget ): close_window() return # yup - ask the user to confirm the close - rc = self.ask( + rc = MainWindow.ask( "This scenario has been changed\n\nDo you want to close the program, and lose your changes?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No @@ -149,17 +167,30 @@ class MainWindow( QWidget ): self._view.page().runJavaScript( "is_scenario_dirty()", callback ) evt.ignore() # nb: we wait until the Javascript finishes to process the event - def showInfoMsg( self, msg ): + @staticmethod + def showInfoMsg( msg ): """Show an informational message.""" - QMessageBox.information( self , APP_NAME , msg ) + QMessageBox.information( MainWindow.instance, APP_NAME, msg ) - def showErrorMsg( self, msg ): + @staticmethod + def showErrorMsg( msg ): """Show an error message.""" - QMessageBox.warning( self , APP_NAME , msg ) + QMessageBox.warning( MainWindow.instance, APP_NAME, msg ) - def ask( self, msg , buttons , default ) : + @staticmethod + def ask( msg, buttons, default ) : """Ask the user a question.""" - return QMessageBox.question( self , APP_NAME , msg , buttons , default ) + return QMessageBox.question( MainWindow.instance, APP_NAME, msg, buttons, default ) + + def on_exit( self ): + """Menu action handler.""" + self.close() + + def on_settings( self ): + """Menu action handler.""" + from vasl_templates.server_settings import ServerSettingsDialog #pylint: disable=cyclic-import + dlg = ServerSettingsDialog( self ) + dlg.exec_() @pyqtSlot() @log_exceptions( caption="SLOT EXCEPTION" ) @@ -171,16 +202,16 @@ class MainWindow( QWidget ): # load and install the user settings buf = io.StringIO() buf.write( "{" ) - for key in self.settings.allKeys(): + for key in app_settings.allKeys(): if key.startswith( "UserSettings/" ): - buf.write( '"{}": {},'.format( key[13:], self.settings.value(key) ) ) + buf.write( '"{}": {},'.format( key[13:], app_settings.value(key) ) ) buf.write( '"_dummy_": null }' ) buf = buf.getvalue() user_settings = {} try: user_settings = json.loads( buf ) except Exception as ex: #pylint: disable=broad-except - self.showErrorMsg( "Couldn't load the user settings:\n\n{}".format( ex ) ) + MainWindow.showErrorMsg( "Couldn't load the user settings:\n\n{}".format( ex ) ) logging.error( "Couldn't load the user settings: %s", ex ) logging.error( buf ) return @@ -209,16 +240,16 @@ class MainWindow( QWidget ): @pyqtSlot( str ) @log_exceptions( caption="SLOT EXCEPTION" ) - def on_user_settings_change( self, user_settings ): + def on_user_settings_change( self, user_settings ): #pylint: disable=no-self-use """Called when the user changes the user settings.""" # delete all existing keys - for key in self.settings.allKeys(): + for key in app_settings.allKeys(): if key.startswith( "UserSettings/" ): - self.settings.remove( key ) + app_settings.remove( key ) # save the new user settings user_settings = json.loads( user_settings ) for key,val in user_settings.items(): - self.settings.setValue( "UserSettings/{}".format(key) , val ) + app_settings.setValue( "UserSettings/{}".format(key), val ) @pyqtSlot( str ) @log_exceptions( caption="SLOT EXCEPTION" ) diff --git a/vasl_templates/resources/file_browser.png b/vasl_templates/resources/file_browser.png new file mode 100755 index 0000000..6d0cd50 Binary files /dev/null and b/vasl_templates/resources/file_browser.png differ diff --git a/vasl_templates/server_settings.py b/vasl_templates/server_settings.py new file mode 100644 index 0000000..570e913 --- /dev/null +++ b/vasl_templates/server_settings.py @@ -0,0 +1,91 @@ +"""Implement the "server settings" dialog.""" + +import os + +from PyQt5 import uic +from PyQt5.QtWidgets import QDialog, QFileDialog +from PyQt5.QtGui import QIcon + +from vasl_templates.main import app_settings +from vasl_templates.main_window import MainWindow +from vasl_templates.webapp.config.constants import DATA_DIR +from vasl_templates.webapp.file_server.vasl_mod import VaslMod +from vasl_templates.webapp.files import install_vasl_mod + +# --------------------------------------------------------------------- + +class ServerSettingsDialog( QDialog ): + """Let the user manage the server settings.""" + + def __init__( self, parent ) : + + # initialize + super().__init__( parent=parent ) + + # initialize the UI + base_dir = os.path.split( __file__ )[0] + dname = os.path.join( base_dir, "ui/server_settings.ui" ) + uic.loadUi( dname, self ) + self.select_vasl_mod_button.setIcon( + QIcon( os.path.join( base_dir, "resources/file_browser.png" ) ) + ) + self.setMinimumSize( self.size() ) + + # initialize handlers + self.select_vasl_mod_button.clicked.connect( self.on_select_vasl_mod ) + self.ok_button.clicked.connect( self.on_ok ) + self.cancel_button.clicked.connect( self.on_cancel ) + + # load the current server settings + self.vasl_mod.setText( app_settings.value( "ServerSettings/vasl-mod" ) ) + + def on_select_vasl_mod( self ): + """Let the user select a VASL module.""" + fname = QFileDialog.getOpenFileName( + self, "Select VASL module", + app_settings.value( "ServerSettings/vasl-mod" ), + "VASL module files (*.vmod)|All files (*.*)" + )[0] + if fname: + self.vasl_mod.setText( fname ) + + def on_ok( self ): + """Accept the new server settings.""" + + # save the new settings + fname = self.vasl_mod.text().strip() + vasl_mod_changed = fname != app_settings.value( "ServerSettings/vasl-mod" ) + app_settings.setValue( "ServerSettings/vasl-mod", fname ) + + # install the new settings + # NOTE: We should really do this before saving the new settings, but that's more trouble + # than it's worth at this stage... :-/ + try: + install_server_settings() + except Exception as ex: #pylint: disable=broad-except + MainWindow.showErrorMsg( "Couldn't install the server settings:\n\n{}".format( ex ) ) + return + self.close() + + # check if the VASL module was changed + if vasl_mod_changed: + # NOTE: It would be nice not to require a restart, but calling QWebEngineProfile.clearHttpCache() doesn't + # seem to, ya know, clear the cache, nor does setting the cache type to NoCache seem to do anything :-/ + MainWindow.showInfoMsg( "The VASL module was changed - you should restart the program." ) + + def on_cancel( self ): + """Cancel the dialog.""" + self.close() + +# --------------------------------------------------------------------- + +def install_server_settings(): + """Install the server settings.""" + + # load the VASL module + fname = app_settings.value( "ServerSettings/vasl-mod" ) + if fname: + vasl_mod = VaslMod( fname, DATA_DIR ) + else: + vasl_mod = None + install_vasl_mod( vasl_mod ) diff --git a/vasl_templates/ui/server_settings.ui b/vasl_templates/ui/server_settings.ui new file mode 100644 index 0000000..83b83b4 --- /dev/null +++ b/vasl_templates/ui/server_settings.ui @@ -0,0 +1,204 @@ + + + Dialog + + + Qt::ApplicationModal + + + + 0 + 0 + 400 + 90 + + + + Server settings + + + true + + + + + + + 0 + 0 + + + + + 0 + 30 + + + + + 16777215 + 30 + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + &VASL module: + + + vasl_mod + + + + + + + + + + + 0 + 0 + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + true + + + + + vasl_mod + label + select_vasl_mod_button + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + + 0 + 30 + + + + + 16777215 + 30 + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 309 + 20 + + + + + + + + + 0 + 0 + + + + OK + + + true + + + + + + + + 0 + 0 + + + + Cancel + + + + + + + + + + + diff --git a/vasl_templates/webapp/files.py b/vasl_templates/webapp/files.py index 2366b3b..ca15d7f 100644 --- a/vasl_templates/webapp/files.py +++ b/vasl_templates/webapp/files.py @@ -15,6 +15,13 @@ if app.config.get( "VASL_MOD" ): # --------------------------------------------------------------------- +def install_vasl_mod( new_vasl_mod ): + """Install a new VASL module.""" + global vasl_mod + vasl_mod = new_vasl_mod + +# --------------------------------------------------------------------- + @app.route( "/counter///" ) @app.route( "/counter//", defaults={"index":0} ) def get_counter_image( gpid, side, index ): diff --git a/vasl_templates/webapp/templates/user-settings-dialog.html b/vasl_templates/webapp/templates/user-settings-dialog.html index f3e382f..f6decaa 100644 --- a/vasl_templates/webapp/templates/user-settings-dialog.html +++ b/vasl_templates/webapp/templates/user-settings-dialog.html @@ -1,4 +1,4 @@