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. """
import os
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.QtWebChannel import QWebChannel
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 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/(.*)$" )
# ---------------------------------------------------------------------
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 ):
"""Main webapp page."""
"""Application web page."""
def javaScriptConsoleMessage( self, level, msg, line_no, source_id ): #pylint: disable=unused-argument,no-self-use
"""Log a Javascript console message."""
@ -47,19 +31,14 @@ class AppWebPage( QWebEnginePage ):
class MainWindow( QWidget ):
"""Main application window."""
_main_window = None
_curr_scenario_fname = None
def __init__( self, url ):
# initialize
assert MainWindow._main_window is None
MainWindow._main_window = self
self.view = None
super().__init__()
self._view = None
self._is_closing = False
# initialize
super().__init__()
self.setWindowTitle( APP_NAME )
# initialize the layout
@ -72,28 +51,27 @@ class MainWindow( QWidget ):
if not webapp.config.get( "DISABLE_WEBENGINEVIEW" ):
# initialize the web view
self.view = QWebEngineView()
layout.addWidget( self.view )
self._view = QWebEngineView()
layout.addWidget( self._view )
# initialize the web page
# nb: we create an off-the-record profile to stop the view from using cached JS files :-/
profile = QWebEngineProfile( None, self.view )
profile.downloadRequested.connect( self.onDownloadRequested )
page = AppWebPage( profile, self.view )
self.view.setPage( page )
profile = QWebEngineProfile( None, self._view )
page = AppWebPage( profile, self._view )
self._view.setPage( page )
# create a web channel to communicate with the front-end
web_channel = QWebChannel( page )
# 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.
# 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 )
page.setWebChannel( web_channel )
# load the webapp
url += "?pyqt=1"
self.view.load( QUrl(url) )
self._view.load( QUrl(url) )
else:
@ -109,7 +87,7 @@ class MainWindow( QWidget ):
"""Handle requests to close the window (i.e. exit the application)."""
# 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
# check if the scenario is dirty
@ -121,7 +99,7 @@ class MainWindow( QWidget ):
self.close()
return
# 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?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
@ -130,36 +108,41 @@ class MainWindow( QWidget ):
# confirmed - close the window
self._is_closing = True
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
@staticmethod
def onDownloadRequested( item ):
"""Handle download requests."""
# ask the user where to save the scenario
dlg = QFileDialog(
MainWindow._main_window, "Save scenario",
os.path.split(MainWindow._curr_scenario_fname)[0] if MainWindow._curr_scenario_fname else None,
"Scenario files (*.json);;All files(*)"
)
dlg.setDefaultSuffix( ".json" )
if MainWindow._curr_scenario_fname:
dlg.selectFile( os.path.split(MainWindow._curr_scenario_fname)[1] )
fname, _ = QFileDialog.getSaveFileName(
MainWindow._main_window, "Save scenario",
None,
"Scenario files (*.json);;All files(*)"
)
if not fname:
return
def showInfoMsg( self, msg ):
"""Show an informational message."""
QMessageBox.information( self , APP_NAME , msg )
def showErrorMsg( self, msg ):
"""Show an error message."""
QMessageBox.warning( self , APP_NAME , msg )
def ask( self, msg , buttons , default ) :
"""Ask the user a question."""
return QMessageBox.question( self , APP_NAME , msg , buttons , default )
@pyqtSlot()
@log_exceptions( caption="SLOT EXCEPTION" )
def on_new_scenario( self):
"""Called when the user wants to load a scenario."""
self._web_channel_handler.on_new_scenario()
@pyqtSlot( result=str )
@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
item.setPath( fname )
item.accept()
MainWindow._curr_scenario_fname = fname
@pyqtSlot( str, result=bool )
@log_exceptions( caption="SLOT EXCEPTION" )
def save_scenario( self, data ):
"""Called when the user wants to save a scenario."""
return self._web_channel_handler.save_scenario( data )
@pyqtSlot( str )
@log_exceptions( caption="SLOT EXCEPTION" )
def on_scenario_name_change( self, val ):
"""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>).
if ( getUrlParam( "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
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
$("#load-scenario").trigger( "click" ) ;
}
@ -470,21 +480,24 @@ function on_load_scenario_file_selected()
{
// read the selected file
var fileReader = new FileReader() ;
fileReader.onload = function() {
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.onload = function() { do_load_scenario( fileReader.result ) ; } ;
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_scenario() ;
@ -605,18 +618,34 @@ function on_save_scenario()
var params = unload_params_for_save() ;
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
// 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" ) ) {
$("#_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 the parameters to the user as a downloadable file
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()
@ -663,11 +692,11 @@ function on_new_scenario( verbose )
function do_on_new_scenario() {
// load the default scenario
if ( gDefaultScenario )
do_load_scenario( gDefaultScenario ) ;
do_load_scenario_data( gDefaultScenario ) ;
else {
$.getJSON( gGetDefaultScenarioUrl, function(data) {
gDefaultScenario = data ;
do_load_scenario( data ) ;
do_load_scenario_data( data ) ;
update_page_load_status( "default-scenario" ) ;
} ).fail( function( xhr, status, errorMsg ) {
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
if ( verbose )
showInfoMsg( "The scenario was reset." ) ;

Loading…
Cancel
Save