Delegate loading/saving scenarios to the PyQt wrapper app.

master
Pacman Ghost 6 years ago
parent 9ec225726b
commit f64713b75c
  1. 111
      vasl_templates/main_window.py
  2. 23
      vasl_templates/utils.py
  3. 88
      vasl_templates/web_channel.py
  4. 71
      vasl_templates/webapp/static/snippets.js

@ -1,40 +1,24 @@
""" Main application window. """ """ Main application window. """
import os
import re import re
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QFileDialog, QMessageBox from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QMessageBox
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage
from PyQt5.QtWebChannel import QWebChannel from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from PyQt5.QtCore import QObject, QUrl, pyqtSlot from PyQt5.QtCore import QUrl, pyqtSlot
from vasl_templates.webapp.config.constants import APP_NAME from vasl_templates.webapp.config.constants import APP_NAME
from vasl_templates.webapp import app as webapp from vasl_templates.webapp import app as webapp
from vasl_templates.web_channel import WebChannelHandler
from vasl_templates.utils import log_exceptions
_CONSOLE_SOURCE_REGEX = re.compile( r"^http://.+?/static/(.*)$" ) _CONSOLE_SOURCE_REGEX = re.compile( r"^http://.+?/static/(.*)$" )
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
class WebChannelHandler( QObject ):
"""Handle web channel requests."""
def __init__( self, window ):
# initialize
super().__init__()
self.window = window
@pyqtSlot( str )
def on_scenario_name_change( self, val ):
"""Update the main window title to show the scenario name."""
self.window.setWindowTitle(
"{} - {}".format( APP_NAME, val ) if val else APP_NAME
)
# ---------------------------------------------------------------------
class AppWebPage( QWebEnginePage ): class AppWebPage( QWebEnginePage ):
"""Main webapp page.""" """Application web page."""
def javaScriptConsoleMessage( self, level, msg, line_no, source_id ): #pylint: disable=unused-argument,no-self-use def javaScriptConsoleMessage( self, level, msg, line_no, source_id ): #pylint: disable=unused-argument,no-self-use
"""Log a Javascript console message.""" """Log a Javascript console message."""
@ -47,19 +31,14 @@ class AppWebPage( QWebEnginePage ):
class MainWindow( QWidget ): class MainWindow( QWidget ):
"""Main application window.""" """Main application window."""
_main_window = None
_curr_scenario_fname = None
def __init__( self, url ): def __init__( self, url ):
# initialize # initialize
assert MainWindow._main_window is None super().__init__()
MainWindow._main_window = self self._view = None
self.view = None
self._is_closing = False self._is_closing = False
# initialize # initialize
super().__init__()
self.setWindowTitle( APP_NAME ) self.setWindowTitle( APP_NAME )
# initialize the layout # initialize the layout
@ -72,28 +51,27 @@ class MainWindow( QWidget ):
if not webapp.config.get( "DISABLE_WEBENGINEVIEW" ): if not webapp.config.get( "DISABLE_WEBENGINEVIEW" ):
# initialize the web view # initialize the web view
self.view = QWebEngineView() self._view = QWebEngineView()
layout.addWidget( self.view ) layout.addWidget( self._view )
# initialize the web page # initialize the web page
# nb: we create an off-the-record profile to stop the view from using cached JS files :-/ # nb: we create an off-the-record profile to stop the view from using cached JS files :-/
profile = QWebEngineProfile( None, self.view ) profile = QWebEngineProfile( None, self._view )
profile.downloadRequested.connect( self.onDownloadRequested ) page = AppWebPage( profile, self._view )
page = AppWebPage( profile, self.view ) self._view.setPage( page )
self.view.setPage( page )
# create a web channel to communicate with the front-end # create a web channel to communicate with the front-end
web_channel = QWebChannel( page ) web_channel = QWebChannel( page )
# FUDGE! We would like to register a WebChannelHandler instance as the handler, but this crashes PyQt :-/ # FUDGE! We would like to register a WebChannelHandler instance as the handler, but this crashes PyQt :-/
# Instead, we register ourself as the handler, and delegate processing to a WebChannelHandler. # Instead, we register ourself as the handler, and delegate processing to a WebChannelHandler.
# The downside is that PyQt emits lots of warnings about our member variables not being properties :-/ # The downside is that PyQt emits lots of warnings about our member variables not being properties :-/
self.web_channel_handler = WebChannelHandler( self ) self._web_channel_handler = WebChannelHandler( self )
web_channel.registerObject( "handler", self ) web_channel.registerObject( "handler", self )
page.setWebChannel( web_channel ) page.setWebChannel( web_channel )
# load the webapp # load the webapp
url += "?pyqt=1" url += "?pyqt=1"
self.view.load( QUrl(url) ) self._view.load( QUrl(url) )
else: else:
@ -109,7 +87,7 @@ class MainWindow( QWidget ):
"""Handle requests to close the window (i.e. exit the application).""" """Handle requests to close the window (i.e. exit the application)."""
# check if we need to check for a dirty scenario # check if we need to check for a dirty scenario
if self.view is None or self._is_closing: if self._view is None or self._is_closing:
return return
# check if the scenario is dirty # check if the scenario is dirty
@ -121,7 +99,7 @@ class MainWindow( QWidget ):
self.close() self.close()
return return
# yup - ask the user to confirm the close # yup - ask the user to confirm the close
rc = QMessageBox.question( self, "Close program", rc = self.ask(
"This scenario has been changed\n\nDo you want to close the program, and lose your changes?", "This scenario has been changed\n\nDo you want to close the program, and lose your changes?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No QMessageBox.No
@ -130,36 +108,41 @@ class MainWindow( QWidget ):
# confirmed - close the window # confirmed - close the window
self._is_closing = True self._is_closing = True
self.close() self.close()
self.view.page().runJavaScript( "is_scenario_dirty()", callback ) self._view.page().runJavaScript( "is_scenario_dirty()", callback )
evt.ignore() # nb: we wait until the Javascript finishes to process the event evt.ignore() # nb: we wait until the Javascript finishes to process the event
@staticmethod def showInfoMsg( self, msg ):
def onDownloadRequested( item ): """Show an informational message."""
"""Handle download requests.""" QMessageBox.information( self , APP_NAME , msg )
# ask the user where to save the scenario def showErrorMsg( self, msg ):
dlg = QFileDialog( """Show an error message."""
MainWindow._main_window, "Save scenario", QMessageBox.warning( self , APP_NAME , msg )
os.path.split(MainWindow._curr_scenario_fname)[0] if MainWindow._curr_scenario_fname else None,
"Scenario files (*.json);;All files(*)" def ask( self, msg , buttons , default ) :
) """Ask the user a question."""
dlg.setDefaultSuffix( ".json" ) return QMessageBox.question( self , APP_NAME , msg , buttons , default )
if MainWindow._curr_scenario_fname:
dlg.selectFile( os.path.split(MainWindow._curr_scenario_fname)[1] ) @pyqtSlot()
fname, _ = QFileDialog.getSaveFileName( @log_exceptions( caption="SLOT EXCEPTION" )
MainWindow._main_window, "Save scenario", def on_new_scenario( self):
None, """Called when the user wants to load a scenario."""
"Scenario files (*.json);;All files(*)" self._web_channel_handler.on_new_scenario()
)
if not fname: @pyqtSlot( result=str )
return @log_exceptions( caption="SLOT EXCEPTION" )
def load_scenario( self ):
"""Called when the user wants to load a scenario."""
return self._web_channel_handler.load_scenario()
# accept the download request @pyqtSlot( str, result=bool )
item.setPath( fname ) @log_exceptions( caption="SLOT EXCEPTION" )
item.accept() def save_scenario( self, data ):
MainWindow._curr_scenario_fname = fname """Called when the user wants to save a scenario."""
return self._web_channel_handler.save_scenario( data )
@pyqtSlot( str ) @pyqtSlot( str )
@log_exceptions( caption="SLOT EXCEPTION" )
def on_scenario_name_change( self, val ): def on_scenario_name_change( self, val ):
"""Update the main window title to show the scenario name.""" """Update the main window title to show the scenario name."""
self.web_channel_handler.on_scenario_name_change( val ) self._web_channel_handler.on_scenario_name_change( val )

@ -0,0 +1,23 @@
""" Miscellaneous utilities. """
import functools
import logging
import traceback
# ---------------------------------------------------------------------
def log_exceptions( caption="EXCEPTION" ):
"""Decorator that logs exceptions thrown by the wrapped function."""
def decorator( func ):
"""The real decorator function."""
@functools.wraps( func )
def wrapper( *args, **kwargs ):
"""Wrapper around the function being decorated."""
try:
return func( *args, **kwargs )
except Exception as ex:
logging.critical( "%s: %s", caption, ex )
logging.critical( traceback.format_exc() )
raise
return wrapper
return decorator

@ -0,0 +1,88 @@
""" Web channel handler. """
import os
from PyQt5.QtWidgets import QFileDialog
from vasl_templates.webapp.config.constants import APP_NAME
# ---------------------------------------------------------------------
class WebChannelHandler:
"""Handle web channel requests."""
_FILE_FILTERS = "Scenario files (*.json);;All files (*)"
def __init__( self, window ):
# initialize
self._window = window
# NOTE: While loading/saving scenarios works fine when handled by the embedded browser,
# we can't get the full path of the file saved loaded (because of browser security).
# This means that we can't e.g. default saving a scenario to the same file it was loaded from.
# This is such a lousy UX, we handle load/save operations ourself, where we can manage this.
self._curr_scenario_fname = None
def on_new_scenario( self ):
"""Called when the scenario is reset."""
self._curr_scenario_fname = None
def load_scenario( self ):
"""Called when the user wants to load a scenario."""
# ask the user which file to load
fname, _ = QFileDialog.getOpenFileName(
self._window, "Load scenario",
os.path.split(self._curr_scenario_fname)[0] if self._curr_scenario_fname else None,
WebChannelHandler._FILE_FILTERS
)
if not fname:
return None
# load the scenario
try:
with open( fname, "r", encoding="utf-8" ) as fp:
data = fp.read()
except Exception as ex: #pylint: disable=broad-except
self._window.showErrorMsg( "Can't load the scenario:\n\n{}".format( ex ) )
return None
self._curr_scenario_fname = fname
return data
def save_scenario( self, data ):
"""Called when the user wants to save a scenario."""
# ask the user where to save the scenario
fname, _ = QFileDialog.getSaveFileName(
self._window, "Save scenario",
self._curr_scenario_fname,
WebChannelHandler._FILE_FILTERS
)
if not fname:
return False
# check the file extension
extn = os.path.splitext( fname )[1]
if not extn:
fname += ".json"
elif fname.endswith( "." ):
fname = fname[:-1]
# save the file
try:
with open( fname, "w", encoding="utf-8" ) as fp:
fp.write( data )
except Exception as ex: #pylint: disable=broad-except
self._window.showErrorMsg( "Can't save the scenario:\n\n{}".format( ex ) )
return False
self._curr_scenario_fname = fname
return True
def on_scenario_name_change( self, val ):
"""Update the main window title to show the scenario name."""
self._window.setWindowTitle(
"{} - {}".format( APP_NAME, val ) if val else APP_NAME
)

@ -456,11 +456,21 @@ function on_load_scenario()
// the browser will use native controls), so we get the data from a <textarea>). // the browser will use native controls), so we get the data from a <textarea>).
if ( getUrlParam( "scenario_persistence" ) ) { if ( getUrlParam( "scenario_persistence" ) ) {
var $elem = $( "#_scenario-persistence_" ) ; var $elem = $( "#_scenario-persistence_" ) ;
do_load_scenario( JSON.parse( $elem.val() ) ) ; do_load_scenario( $elem.val() ) ;
showInfoMsg( "The scenario was loaded." ) ; // nb: the tests are looking for this showInfoMsg( "The scenario was loaded." ) ; // nb: the tests are looking for this
return ; return ;
} }
// if we are running inside the PyQt wrapper, let it handle everything
if ( gWebChannelHandler ) {
gWebChannelHandler.load_scenario( function( data ) {
if ( ! data )
return ;
do_load_scenario( data ) ;
} ) ;
return ;
}
// ask the user to upload the scenario file // ask the user to upload the scenario file
$("#load-scenario").trigger( "click" ) ; $("#load-scenario").trigger( "click" ) ;
} }
@ -470,21 +480,24 @@ function on_load_scenario_file_selected()
{ {
// read the selected file // read the selected file
var fileReader = new FileReader() ; var fileReader = new FileReader() ;
fileReader.onload = function() { fileReader.onload = function() { do_load_scenario( fileReader.result ) ; } ;
var data ;
try {
data = JSON.parse( fileReader.result ) ;
} catch( ex ) {
showErrorMsg( "Can't load the scenario file:<div class='pre'>" + escapeHTML(ex) + "</div>" ) ;
return ;
}
do_load_scenario( data ) ;
showInfoMsg( "The scenario was loaded." ) ;
} ;
fileReader.readAsText( $("#load-scenario").prop("files")[0] ) ; fileReader.readAsText( $("#load-scenario").prop("files")[0] ) ;
} }
function do_load_scenario( params ) function do_load_scenario( data )
{
// load the scenario
try {
data = JSON.parse( data ) ;
} catch( ex ) {
showErrorMsg( "Can't load the scenario file:<div class='pre'>" + escapeHTML(ex) + "</div>" ) ;
return ;
}
do_load_scenario_data( data ) ;
showInfoMsg( "The scenario was loaded." ) ;
}
function do_load_scenario_data( params )
{ {
// reset the scenario // reset the scenario
reset_scenario() ; reset_scenario() ;
@ -605,18 +618,34 @@ function on_save_scenario()
var params = unload_params_for_save() ; var params = unload_params_for_save() ;
var data = JSON.stringify( params ) ; var data = JSON.stringify( params ) ;
// remember this as the last saved scenario
gLastSavedScenario = params ;
// FOR TESTING PORPOISES! We can't control a file download from Selenium (since // FOR TESTING PORPOISES! We can't control a file download from Selenium (since
// the browser will use native controls), so we store the result in a <textarea>). // the browser will use native controls), so we store the result in a <textarea>
// and the test suite will collect it from there).
if ( getUrlParam( "scenario_persistence" ) ) { if ( getUrlParam( "scenario_persistence" ) ) {
$("#_scenario-persistence_").val( data ) ; $("#_scenario-persistence_").val( data ) ;
gLastSavedScenario = params ;
return ;
}
// if we are running inside the PyQt wrapper, let it handle everything
if ( gWebChannelHandler ) {
gWebChannelHandler.save_scenario( data, function( result ) {
if ( ! result )
return ;
gLastSavedScenario = params ;
showInfoMsg( "The scenario was saved." ) ;
} ) ;
return ; return ;
} }
// return the parameters to the user as a downloadable file // return the parameters to the user as a downloadable file
download( data, "scenario.json", "application/json" ) ; download( data, "scenario.json", "application/json" ) ;
// NOTE: We get no indication if the download was successful, so we can't show feedback :-/
// Also, if the download didn't actually happen (e.g. because it was cancelled), then setting
// the last saved scenario here is not quite the right thing to do, since subsequent checks
// for a dirty scenario will return the wrong result, since they assume that the scenario
// was saved properly here :-/
gLastSavedScenario = params ;
} }
function unload_params_for_save() function unload_params_for_save()
@ -663,11 +692,11 @@ function on_new_scenario( verbose )
function do_on_new_scenario() { function do_on_new_scenario() {
// load the default scenario // load the default scenario
if ( gDefaultScenario ) if ( gDefaultScenario )
do_load_scenario( gDefaultScenario ) ; do_load_scenario_data( gDefaultScenario ) ;
else { else {
$.getJSON( gGetDefaultScenarioUrl, function(data) { $.getJSON( gGetDefaultScenarioUrl, function(data) {
gDefaultScenario = data ; gDefaultScenario = data ;
do_load_scenario( data ) ; do_load_scenario_data( data ) ;
update_page_load_status( "default-scenario" ) ; update_page_load_status( "default-scenario" ) ;
} ).fail( function( xhr, status, errorMsg ) { } ).fail( function( xhr, status, errorMsg ) {
showErrorMsg( "Can't get the default scenario:<div class='pre'>" + escapeHTML(errorMsg) + "</div>" ) ; showErrorMsg( "Can't get the default scenario:<div class='pre'>" + escapeHTML(errorMsg) + "</div>" ) ;
@ -675,6 +704,10 @@ function on_new_scenario( verbose )
} ) ; } ) ;
} }
// notify the PyQt wrapper application
if ( gWebChannelHandler )
gWebChannelHandler.on_new_scenario() ;
// provide some feedback to the user // provide some feedback to the user
if ( verbose ) if ( verbose )
showInfoMsg( "The scenario was reset." ) ; showInfoMsg( "The scenario was reset." ) ;

Loading…
Cancel
Save