Index: src/sas/qtgui/GUITests.py
===================================================================
--- src/sas/qtgui/GUITests.py	(revision da9a07226e9e59e05151f1ed443b9554788d1294)
+++ src/sas/qtgui/GUITests.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -44,4 +44,8 @@
 from Utilities.UnitTesting import GuiUtilsTest
 from Utilities.UnitTesting import SasviewLoggerTest
+from Utilities.UnitTesting import GridPanelTest
+from Utilities.UnitTesting import ModelEditorTest
+from Utilities.UnitTesting import PluginDefinitionTest
+from Utilities.UnitTesting import TabbedModelEditorTest
 
 # Unit Testing
@@ -99,4 +103,8 @@
         unittest.makeSuite(GuiUtilsTest.DoubleValidatorTest,      'test'),
         unittest.makeSuite(GuiUtilsTest.HashableStandardItemTest, 'test'),
+        unittest.makeSuite(GridPanelTest.BatchOutputPanelTest,    'test'),
+        unittest.makeSuite(ModelEditorTest.ModelEditorTest,            'test'),
+        unittest.makeSuite(PluginDefinitionTest.PluginDefinitionTest,  'test'),
+        unittest.makeSuite(TabbedModelEditorTest.TabbedModelEditorTest,'test'),
 
         # Calculators
Index: src/sas/qtgui/MainWindow/DataExplorer.py
===================================================================
--- src/sas/qtgui/MainWindow/DataExplorer.py	(revision e90988cd3c71ed989b47df2bed55bcd11e7733ce)
+++ src/sas/qtgui/MainWindow/DataExplorer.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -476,16 +476,9 @@
         self.chkBatch.setEnabled(self.parent.perspective().allowBatch())
 
-    def displayData(self, data_list):
+    def displayFile(self, filename=None, is_data=True):
         """
         Forces display of charts for the given filename
         """
-        plot_to_show = data_list[0]
-
-        # passed plot is used ONLY to figure out its title,
-        # so all the charts related by it can be pulled from 
-        # the data explorer indices.
-        filename = plot_to_show.filename
-        model = self.model if plot_to_show.is_data else self.theory_model
-
+        model = self.model if is_data else self.theory_model
         # Now query the model item for available plots
         plots = GuiUtils.plotsFromFilename(filename, model)
@@ -506,4 +499,15 @@
         if new_plots:
             self.plotData(new_plots)
+
+    def displayData(self, data_list):
+        """
+        Forces display of charts for the given data set
+        """
+        plot_to_show = data_list[0]
+        # passed plot is used ONLY to figure out its title,
+        # so all the charts related by it can be pulled from 
+        # the data explorer indices.
+        filename = plot_to_show.filename
+        self.displayFile(filename=filename, is_data=plot_to_show.is_data)
 
     def addDataPlot2D(self, plot_set, item):
Index: src/sas/qtgui/MainWindow/GuiManager.py
===================================================================
--- src/sas/qtgui/MainWindow/GuiManager.py	(revision 14ec91c506393c404c69a71e75131fb3d62e4230)
+++ src/sas/qtgui/MainWindow/GuiManager.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -19,4 +19,6 @@
 
 import sas.qtgui.Utilities.ObjectLibrary as ObjectLibrary
+from sas.qtgui.Utilities.TabbedModelEditor import TabbedModelEditor
+from sas.qtgui.Utilities.PluginManager import PluginManager
 from sas.qtgui.MainWindow.UI.AcknowledgementsUI import Ui_Acknowledgements
 from sas.qtgui.MainWindow.AboutBox import AboutBox
@@ -358,4 +360,5 @@
         self.communicate.updateTheoryFromPerspectiveSignal.connect(self.updateTheoryFromPerspective)
         self.communicate.plotRequestedSignal.connect(self.showPlot)
+        self.communicate.plotFromFilenameSignal.connect(self.showPlotFromFilename)
         self.communicate.updateModelFromDataOperationPanelSignal.connect(self.updateModelFromDataOperationPanel)
 
@@ -406,5 +409,7 @@
         self._workspace.actionFit_Results.triggered.connect(self.actionFit_Results)
         self._workspace.actionChain_Fitting.triggered.connect(self.actionChain_Fitting)
+        self._workspace.actionAdd_Custom_Model.triggered.connect(self.actionAdd_Custom_Model)
         self._workspace.actionEdit_Custom_Model.triggered.connect(self.actionEdit_Custom_Model)
+        self._workspace.actionManage_Custom_Models.triggered.connect(self.actionManage_Custom_Models)
         # Window
         self._workspace.actionCascade.triggered.connect(self.actionCascade)
@@ -662,9 +667,21 @@
         pass
 
+    def actionAdd_Custom_Model(self):
+        """
+        """
+        self.model_editor = TabbedModelEditor(self)
+        self.model_editor.show()
+
     def actionEdit_Custom_Model(self):
         """
         """
-        print("actionEdit_Custom_Model TRIGGERED")
-        pass
+        self.model_editor = TabbedModelEditor(self, edit_only=True)
+        self.model_editor.show()
+
+    def actionManage_Custom_Models(self):
+        """
+        """
+        self.model_manager = PluginManager(self)
+        self.model_manager.show()
 
     #============ ANALYSIS =================
@@ -779,4 +796,11 @@
         self.filesWidget.model.appendRow(new_item)
         self._data_manager.add_data(new_datalist_item)
+
+    def showPlotFromFilename(self, filename):
+        """
+        Pass the show plot request to the data explorer
+        """
+        if hasattr(self, "filesWidget"):
+            self.filesWidget.displayFile(filename=filename, is_data=True)
 
     def showPlot(self, plot):
Index: src/sas/qtgui/MainWindow/UI/MainWindowUI.ui
===================================================================
--- src/sas/qtgui/MainWindow/UI/MainWindowUI.ui	(revision 1543f0c992549217b0c16ad382d98c5fa1cd6030)
+++ src/sas/qtgui/MainWindow/UI/MainWindowUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -8,5 +8,5 @@
     <y>0</y>
     <width>915</width>
-    <height>527</height>
+    <height>762</height>
    </rect>
   </property>
@@ -25,5 +25,5 @@
      <y>0</y>
      <width>915</width>
-     <height>21</height>
+     <height>26</height>
     </rect>
    </property>
@@ -108,5 +108,7 @@
     <addaction name="actionChain_Fitting"/>
     <addaction name="separator"/>
+    <addaction name="actionAdd_Custom_Model"/>
     <addaction name="actionEdit_Custom_Model"/>
+    <addaction name="actionManage_Custom_Models"/>
    </widget>
    <widget class="QMenu" name="menuWindow">
@@ -505,4 +507,14 @@
    </property>
   </action>
+  <action name="actionAdd_Custom_Model">
+   <property name="text">
+    <string>Add Custom Model</string>
+   </property>
+  </action>
+  <action name="actionManage_Custom_Models">
+   <property name="text">
+    <string>Manage Custom Models</string>
+   </property>
+  </action>
  </widget>
  <resources/>
Index: src/sas/qtgui/Perspectives/Fitting/ConstraintWidget.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/ConstraintWidget.py	(revision e4c475b769d2f7b453153dd47ec2824b13fe251d)
+++ src/sas/qtgui/Perspectives/Fitting/ConstraintWidget.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -111,5 +111,5 @@
 
         # Disconnect all local slots
-        tab_object.disconnect()
+        #tab_object.disconnect()
 
         # Reconnect tab signals to local slots
@@ -178,7 +178,7 @@
                             "Not all tabs chosen for fitting have parameters selected for fitting."
             QtWidgets.QMessageBox.warning(self,
-                                           'Warning',
-                                            no_params_msg,
-                                            QtWidgets.QMessageBox.Ok)
+                                          'Warning',
+                                           no_params_msg,
+                                           QtWidgets.QMessageBox.Ok)
 
             return
@@ -674,5 +674,7 @@
 
         constraint.func = constraint_text
+        # param1 is the parameter we're constraining
         constraint.param = param1
+
         # Find the right tab
         constrained_tab = self.getObjectByName(model1)
Index: src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py	(revision 14ec91c506393c404c69a71e75131fb3d62e4230)
+++ src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -8,4 +8,5 @@
 from bumps import fitters
 
+import sas.qtgui.Utilities.LocalConfig as LocalConfig
 import sas.qtgui.Utilities.ObjectLibrary as ObjectLibrary
 
@@ -116,4 +117,5 @@
         tab	= FittingWidget(parent=self.parent, data=data, tab_id=self.maxIndex+1)
         tab.is_batch_fitting = is_batch
+
         # Add this tab to the object library so it can be retrieved by scripting/jupyter
         tab_name = self.getTabName(is_batch=is_batch)
@@ -123,5 +125,11 @@
             self.updateFitDict(data, tab_name)
         self.maxIndex += 1
-        self.addTab(tab, tab_name)
+        icon = QtGui.QIcon()
+        if is_batch:
+            icon.addPixmap(QtGui.QPixmap("src/sas/qtgui/images/icons/layers.svg"))
+        self.addTab(tab, icon, tab_name)
+        # Show the new tab
+        self.setCurrentIndex(self.maxIndex-1)
+        # Notify listeners
         self.tabsModifiedSignal.emit()
 
@@ -140,5 +148,10 @@
         ObjectLibrary.addObject(tab_name, tab)
         self.tabs.append(tab)
-        self.addTab(tab, tab_name)
+        icon = QtGui.QIcon()
+        icon.addPixmap(QtGui.QPixmap("src/sas/qtgui/images/icons/link.svg"))
+        self.addTab(tab, icon, tab_name)
+
+        # This will be the last tab, so set the index accordingly
+        self.setCurrentIndex(self.count()-1)
 
     def updateFitDict(self, item_key, tab_name):
Index: src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/FittingWidget.py	(revision e4c475b769d2f7b453153dd47ec2824b13fe251d)
+++ src/sas/qtgui/Perspectives/Fitting/FittingWidget.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -24,4 +24,5 @@
 import sas.qtgui.Utilities.GuiUtils as GuiUtils
 import sas.qtgui.Utilities.LocalConfig as LocalConfig
+from sas.qtgui.Utilities.GridPanel import BatchOutputPanel
 from sas.qtgui.Utilities.CategoryInstaller import CategoryInstaller
 from sas.qtgui.Plotting.PlotterData import Data1D
@@ -36,4 +37,5 @@
 from sas.qtgui.Perspectives.Fitting.FittingLogic import FittingLogic
 from sas.qtgui.Perspectives.Fitting import FittingUtilities
+from sas.qtgui.Perspectives.Fitting import ModelUtilities
 from sas.qtgui.Perspectives.Fitting.SmearingWidget import SmearingWidget
 from sas.qtgui.Perspectives.Fitting.OptionsWidget import OptionsWidget
@@ -50,4 +52,5 @@
 CATEGORY_DEFAULT = "Choose category..."
 CATEGORY_STRUCTURE = "Structure Factor"
+CATEGORY_CUSTOM = "Plugin Models"
 STRUCTURE_DEFAULT = "None"
 
@@ -83,4 +86,7 @@
     constraintAddedSignal = QtCore.pyqtSignal(list)
     newModelSignal = QtCore.pyqtSignal()
+    fittingFinishedSignal = QtCore.pyqtSignal(tuple)
+    batchFittingFinishedSignal = QtCore.pyqtSignal(tuple)
+
     def __init__(self, parent=None, data=None, tab_id=1):
 
@@ -211,4 +217,7 @@
         self.page_stack = []
         self.all_data = []
+        # custom plugin models
+        # {model.name:model}
+        self.custom_models = self.customModels()
         # Polydisp widget table default index for function combobox
         self.orig_poly_index = 3
@@ -415,4 +424,8 @@
             self.onSelectModel()
 
+    def customModels(self):
+        """ Reads in file names in the custom plugin directory """
+        return ModelUtilities._find_models()
+
     def initializeControls(self):
         """
@@ -465,7 +478,15 @@
         self._poly_model.itemChanged.connect(self.onPolyModelChange)
         self._magnet_model.itemChanged.connect(self.onMagnetModelChange)
+        self.lstParams.selectionModel().selectionChanged.connect(self.onSelectionChanged)
+
+        # Local signals
+        self.batchFittingFinishedSignal.connect(self.batchFitComplete)
+        self.fittingFinishedSignal.connect(self.fitComplete)
 
         # Signals from separate tabs asking for replot
         self.options_widget.plot_signal.connect(self.onOptionsUpdate)
+
+        # Signals from other widgets
+        self.communicate.customModelDirectoryChanged.connect(self.onCustomModelChange)
 
     def modelName(self):
@@ -576,7 +597,12 @@
         # widget.params[0] is the parameter we're constraining
         constraint.param = mc_widget.params[0]
-        # Function should have the model name preamble
+        # parameter should have the model name preamble
         model_name = self.kernel_module.name
-        constraint.func = model_name + "." + c_text
+        # param_used is the parameter we're using in constraining function
+        param_used = mc_widget.params[1]
+        # Replace param_used with model_name.param_used
+        updated_param_used = model_name + "." + param_used
+        new_func = c_text.replace(param_used, updated_param_used)
+        constraint.func = new_func
         # Which row is the constrained parameter in?
         row = self.getRowFromName(constraint.param)
@@ -677,5 +703,8 @@
         Delete constraints from selected parameters.
         """
-        self.deleteConstraintOnParameter(param=None)
+        params =  [s.data() for s in self.lstParams.selectionModel().selectedRows()
+                   if self.isCheckable(s.row())]
+        for param in params:
+            self.deleteConstraintOnParameter(param=param)
 
     def deleteConstraintOnParameter(self, param=None):
@@ -686,9 +715,9 @@
         max_col = self.lstParams.itemDelegate().param_max
         for row in range(self._model_model.rowCount()):
+            if not self.rowHasConstraint(row):
+                continue
             # Get the Constraint object from of the model item
             item = self._model_model.item(row, 1)
-            if not item.hasChildren():
-                continue
-            constraint = item.child(0).data()
+            constraint = self.getConstraintForRow(row)
             if constraint is None:
                 continue
@@ -816,4 +845,41 @@
         return constraints
 
+    def getConstraintsForFitting(self):
+        """
+        Return a list of constraints in format ready for use in fiting
+        """
+        # Get constraints
+        constraints = self.getComplexConstraintsForModel()
+        # See if there are any constraints across models
+        multi_constraints = [cons for cons in constraints if self.isConstraintMultimodel(cons[1])]
+
+        if multi_constraints:
+            # Let users choose what to do
+            msg = "The current fit contains constraints relying on other fit pages.\n"
+            msg += "Parameters with those constraints are:\n" +\
+                '\n'.join([cons[0] for cons in multi_constraints])
+            msg += "\n\nWould you like to remove these constraints or cancel fitting?"
+            msgbox = QtWidgets.QMessageBox(self)
+            msgbox.setIcon(QtWidgets.QMessageBox.Warning)
+            msgbox.setText(msg)
+            msgbox.setWindowTitle("Existing Constraints")
+            # custom buttons
+            button_remove = QtWidgets.QPushButton("Remove")
+            msgbox.addButton(button_remove, QtWidgets.QMessageBox.YesRole)
+            button_cancel = QtWidgets.QPushButton("Cancel")
+            msgbox.addButton(button_cancel, QtWidgets.QMessageBox.RejectRole)
+            retval = msgbox.exec_()
+            if retval == QtWidgets.QMessageBox.RejectRole:
+                # cancel fit
+                raise ValueError("Fitting cancelled")
+            else:
+                # remove constraint
+                for cons in multi_constraints:
+                    self.deleteConstraintOnParameter(param=cons[0])
+                # re-read the constraints
+                constraints = self.getComplexConstraintsForModel()
+
+        return constraints
+
     def showModelDescription(self):
         """
@@ -874,4 +940,42 @@
         self.respondToModelStructure(model=model, structure_factor=structure)
 
+    def onCustomModelChange(self):
+        """
+        Reload the custom model combobox
+        """
+        self.custom_models = self.customModels()
+        self.readCustomCategoryInfo()
+        # See if we need to update the combo in-place
+        if self.cbCategory.currentText() != CATEGORY_CUSTOM: return
+
+        current_text = self.cbModel.currentText()
+        self.cbModel.blockSignals(True)
+        self.cbModel.clear()
+        self.cbModel.blockSignals(False)
+        self.enableModelCombo()
+        self.disableStructureCombo()
+        # Retrieve the list of models
+        model_list = self.master_category_dict[CATEGORY_CUSTOM]
+        # Populate the models combobox
+        self.cbModel.addItems(sorted([model for (model, _) in model_list]))
+        new_index = self.cbModel.findText(current_text)
+        if new_index != -1:
+            self.cbModel.setCurrentIndex(self.cbModel.findText(current_text))
+
+    def onSelectionChanged(self):
+        """
+        React to parameter selection
+        """
+        rows = self.lstParams.selectionModel().selectedRows()
+        # Clean previous messages
+        self.communicate.statusBarUpdateSignal.emit("")
+        if len(rows) == 1:
+            # Show constraint, if present
+            row = rows[0].row()
+            if self.rowHasConstraint(row):
+                func = self.getConstraintForRow(row).func
+                if func is not None:
+                    self.communicate.statusBarUpdateSignal.emit("Active constrain: "+func)
+
     def replaceConstraintName(self, old_name, new_name=""):
         """
@@ -886,4 +990,14 @@
                     new_func = func.replace(old_name, new_name)
                     self._model_model.item(row, 1).child(0).data().func = new_func
+
+    def isConstraintMultimodel(self, constraint):
+        """
+        Check if the constraint function text contains current model name
+        """
+        current_model_name = self.kernel_module.name
+        if current_model_name in constraint:
+            return False
+        else:
+            return True
 
     def updateData(self):
@@ -937,5 +1051,5 @@
             self._model_model.clear()
             return
-
+            
         # Safely clear and enable the model combo
         self.cbModel.blockSignals(True)
@@ -1105,9 +1219,9 @@
         except ValueError as ex:
             # This should not happen! GUI explicitly forbids this situation
-            self.communicate.statusBarUpdateSignal.emit('Fitting attempt without parameters.')
+            self.communicate.statusBarUpdateSignal.emit(str(ex))
             return
 
         # Create the fitting thread, based on the fitter
-        completefn = self.batchFitComplete if self.is_batch_fitting else self.fitComplete
+        completefn = self.batchFittingCompleted if self.is_batch_fitting else self.fittingCompleted
 
         calc_fit = FitThread(handler=handler,
@@ -1146,4 +1260,10 @@
         pass
 
+    def batchFittingCompleted(self, result):
+        """
+        Send the finish message from calculate threads to main thread
+        """
+        self.batchFittingFinishedSignal.emit(result)
+
     def batchFitComplete(self, result):
         """
@@ -1152,4 +1272,13 @@
         #re-enable the Fit button
         self.setFittingStopped()
+        # Show the grid panel
+        self.grid_window = BatchOutputPanel(parent=self, output_data=result[0])
+        self.grid_window.show()
+
+    def fittingCompleted(self, result):
+        """
+        Send the finish message from calculate threads to main thread
+        """
+        self.fittingFinishedSignal.emit(result)
 
     def fitComplete(self, result):
@@ -1162,5 +1291,5 @@
 
         if result is None:
-            msg = "Fitting failed after: %s s.\n" % GuiUtils.formatNumber(elapsed)
+            msg = "Fitting failed."
             self.communicate.statusBarUpdateSignal.emit(msg)
             return
@@ -1228,5 +1357,6 @@
         smearing, accuracy, smearing_min, smearing_max = self.smearing_widget.state()
 
-        constraints = self.getComplexConstraintsForModel()
+        constraints = self.getConstraintsForFitting()
+
         smearer = None
         handler = None
@@ -1242,6 +1372,5 @@
                              constraints=constraints)
             except ValueError as ex:
-                logging.error("Setting model parameters failed with: %s" % ex)
-                return
+                raise ValueError("Setting model parameters failed with: %s" % ex)
 
             qmin, qmax, _ = self.logic.computeRangeFromData(data)
@@ -1409,5 +1538,5 @@
         if not dict:
             return
-        if self._model_model.rowCount() == 0:
+        if self._magnet_model.rowCount() == 0:
             return
 
@@ -1555,4 +1684,18 @@
             self.models[model.name] = model
 
+        self.readCustomCategoryInfo()
+
+    def readCustomCategoryInfo(self):
+        """
+        Reads the custom model category
+        """
+        #Looking for plugins
+        self.plugins = list(self.custom_models.values())
+        plugin_list = []
+        for name, plug in self.custom_models.items():
+            self.models[name] = plug
+            plugin_list.append([name, True])
+        self.master_category_dict[CATEGORY_CUSTOM] = plugin_list
+
     def regenerateModelDict(self):
         """
@@ -1662,5 +1805,9 @@
         Setting model parameters into QStandardItemModel based on selected _model_
         """
-        kernel_module = generate.load_kernel_module(model_name)
+        name = model_name
+        if self.cbCategory.currentText() == CATEGORY_CUSTOM:
+            # custom kernel load requires full path
+            name = os.path.join(ModelUtilities.find_plugins_dir(), model_name+".py")
+        kernel_module = generate.load_kernel_module(name)
         self.model_parameters = modelinfo.make_parameter_table(getattr(kernel_module, 'parameters', []))
 
Index: src/sas/qtgui/Perspectives/Fitting/ModelUtilities.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/ModelUtilities.py	(revision b3e8629bf18b74f4214d6552e620fcbc4e73a28c)
+++ src/sas/qtgui/Perspectives/Fitting/ModelUtilities.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -179,4 +179,5 @@
     plugins = {}
     for filename in os.listdir(directory):
+
         name, ext = os.path.splitext(filename)
         if ext == '.py' and not name == '__init__':
@@ -184,5 +185,5 @@
             try:
                 model = load_custom_model(path)
-                model.name = PLUGIN_NAME_BASE + model.name
+                #model.name = PLUGIN_NAME_BASE + model.name
                 plugins[model.name] = model
             except Exception:
@@ -415,10 +416,12 @@
     implement model
     """
-    __modelmanager = ModelManagerBase()
-    cat_model_list = [__modelmanager.model_dictionary[model_name] for model_name \
-                      in list(__modelmanager.model_dictionary.keys()) \
-                      if model_name not in list(__modelmanager.stored_plugins.keys())]
-
-    CategoryInstaller.check_install(model_list=cat_model_list)
+    def __init__(self):
+        self.__modelmanager = ModelManagerBase()
+        self.cat_model_list = [self.__modelmanager.model_dictionary[model_name] for model_name \
+                          in list(self.__modelmanager.model_dictionary.keys()) \
+                          if model_name not in list(self.__modelmanager.stored_plugins.keys())]
+
+        CategoryInstaller.check_install(model_list=self.cat_model_list)
+
     def findModels(self):
         return self.__modelmanager.findModels()
Index: src/sas/qtgui/Perspectives/Fitting/MultiConstraint.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/MultiConstraint.py	(revision 14ec91c506393c404c69a71e75131fb3d62e4230)
+++ src/sas/qtgui/Perspectives/Fitting/MultiConstraint.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -114,12 +114,12 @@
 
         # 3. parameter name should be a separate word, but can have "()[]*+-/ " around
-        valid_neighbours = "()[]*+-/ "
-        start_loc = parameter_string_start -1
-        end_loc = parameter_string_end
-        if not any([constraint_text[start_loc] == ch for ch in valid_neighbours]):
-            return False
-        if end_loc < len(constraint_text):
-            if not any([constraint_text[end_loc] == ch for ch in valid_neighbours]):
-                return False
+        #valid_neighbours = "()[]*+-/ "
+        #start_loc = parameter_string_start -1
+        #end_loc = parameter_string_end
+        #if not any([constraint_text[start_loc] == ch for ch in valid_neighbours]):
+        #    return False
+        #if end_loc < len(constraint_text):
+        #    if not any([constraint_text[end_loc] == ch for ch in valid_neighbours]):
+        #        return False
 
         # 4. replace parameter name with "1" and try to evaluate the expression
Index: src/sas/qtgui/Perspectives/Fitting/UI/FittingWidgetUI.ui
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/UI/FittingWidgetUI.ui	(revision e4c475b769d2f7b453153dd47ec2824b13fe251d)
+++ src/sas/qtgui/Perspectives/Fitting/UI/FittingWidgetUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -7,5 +7,5 @@
     <x>0</x>
     <y>0</y>
-    <width>568</width>
+    <width>680</width>
     <height>605</height>
    </rect>
Index: src/sas/qtgui/Perspectives/Fitting/UnitTesting/ConstraintWidgetTest.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/UnitTesting/ConstraintWidgetTest.py	(revision 725d9c06c2a66018f1352faba2f96cd31d8e9654)
+++ src/sas/qtgui/Perspectives/Fitting/UnitTesting/ConstraintWidgetTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -22,5 +22,4 @@
 if not QtWidgets.QApplication.instance():
     app = QtWidgets.QApplication(sys.argv)
-#app = QtWidgets.QApplication(sys.argv)
 
 class ConstraintWidgetTest(unittest.TestCase):
@@ -71,5 +70,5 @@
         # click on "batch"
         QTest.mouseClick(self.widget.btnBatch, QtCore.Qt.LeftButton)
-        app.processEvents()
+        QtWidgets.QApplication.processEvents()
         # See what the current type is now
         self.assertEqual(self.widget.currentType, "BatchPage")
@@ -78,5 +77,5 @@
         # Go back to single fit
         QTest.mouseClick(self.widget.btnSingle, QtCore.Qt.LeftButton)
-        app.processEvents()
+        QtWidgets.QApplication.processEvents()
         # See what the current type is now
         self.assertEqual(self.widget.currentType, "FitPage")
Index: src/sas/qtgui/Utilities/GridPanel.py
===================================================================
--- src/sas/qtgui/Utilities/GridPanel.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/GridPanel.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,280 @@
+import os
+import time
+import logging
+import webbrowser
+
+from PyQt5 import QtCore, QtGui, QtWidgets
+
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+
+from sas.qtgui.Utilities.UI.GridPanelUI import Ui_GridPanelUI
+
+
+class BatchOutputPanel(QtWidgets.QMainWindow, Ui_GridPanelUI):
+    """
+    Class for stateless grid-like printout of model parameters for mutiple models
+    """
+    def __init__(self, parent = None, output_data=None):
+
+        super(BatchOutputPanel, self).__init__()
+        self.setupUi(self)
+
+        self.data = output_data
+        self.parent = parent
+        if hasattr(self.parent, "communicate"):
+            self.communicate = parent.communicate
+
+        self.addToolbarActions()
+
+        # file name for the dataset
+        self.grid_filename = ""
+
+        # context menu on the table
+        self.tblParams.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+        self.tblParams.customContextMenuRequested.connect(self.showContextMenu)
+
+        # Fill in the table from input data
+        self.setupTable(output_data)
+
+        # Command buttons
+        self.cmdHelp.clicked.connect(self.onHelp)
+
+    def addToolbarActions(self):
+        """
+        Assing actions and callbacks to the File menu items
+        """
+        self.actionOpen.triggered.connect(self.actionLoadData)
+        self.actionOpen_with_Excel.triggered.connect(self.actionSendToExcel)
+        self.actionSave.triggered.connect(self.actionSaveFile)
+
+    def actionLoadData(self):
+        """
+        Open file load dialog and load a .csv file
+        """
+        datafile = QtWidgets.QFileDialog.getOpenFileName(
+            self, "Choose a file with results", "", "CSV files (*.csv)", None,
+            QtWidgets.QFileDialog.DontUseNativeDialog)[0]
+
+        if not datafile:
+            logging.info("No data file chosen.")
+            return
+
+        with open(datafile, 'r') as csv_file:
+            lines = csv_file.readlines()
+
+        self.setupTableFromCSV(lines)
+
+    def showContextMenu(self, position):
+        """
+        Show context specific menu in the tab table widget.
+        """
+        menu = QtWidgets.QMenu()
+        rows = [s.row() for s in self.tblParams.selectionModel().selectedRows()]
+        num_rows = len(rows)
+        if num_rows <= 0:
+            return
+        # Find out which items got selected and in which row
+        # Select for fitting
+
+        self.actionPlotResults = QtWidgets.QAction(self)
+        self.actionPlotResults.setObjectName("actionPlot")
+        self.actionPlotResults.setText(QtCore.QCoreApplication.translate("self", "Plot selected fits."))
+
+        menu.addAction(self.actionPlotResults)
+
+        # Define the callbacks
+        self.actionPlotResults.triggered.connect(self.plotFits)
+        try:
+            menu.exec_(self.tblParams.viewport().mapToGlobal(position))
+        except AttributeError as ex:
+            logging.error("Error generating context menu: %s" % ex)
+        return
+
+    def onHelp(self):
+        """
+        Open a local url in the default browser
+        """
+        location = GuiUtils.HELP_DIRECTORY_LOCATION
+        url = "/user/sasgui/perspectives/fitting/fitting_help.html#batch-fit-mode"
+        try:
+            webbrowser.open('file://' + os.path.realpath(location+url))
+        except webbrowser.Error as ex:
+            logging.warning("Cannot display help. %s" % ex)
+
+    def plotFits(self):
+        """
+        Plot selected fits by sending signal to the parent
+        """
+        rows = [s.row() for s in self.tblParams.selectionModel().selectedRows()]
+        data = self.dataFromTable(self.tblParams)
+        # data['Data'] -> ['filename1', 'filename2', ...]
+        # look for the 'Data' column and extract the filename
+        for row in rows:
+            try:
+                filename = data['Data'][row]
+                # emit a signal so the plots are being shown
+                self.communicate.plotFromFilenameSignal.emit(filename)
+            except (IndexError, AttributeError):
+                # data messed up.
+                return
+
+    def dataFromTable(self, table):
+        """
+        Creates a dictionary {<parameter>:[list of values]} from the parameter table
+        """
+        assert(isinstance(table, QtWidgets.QTableWidget))
+        params = {}
+        #return {"sld_solvent":[1.2, 2.0, 0.0], "scale":[1.0, 2.0, 3.0]}
+        for column in range(table.columnCount()):
+            #pass
+            value = [table.item(row, column).data(0) for row in range(table.rowCount())]
+            key = table.horizontalHeaderItem(column).data(0)
+            params[key] = value
+        return params
+
+    def actionSendToExcel(self):
+        """
+        Generates a .csv file and opens the default CSV reader
+        """
+        if not self.grid_filename:
+            import tempfile
+            tmpfile = tempfile.NamedTemporaryFile(delete=False, mode="w+", suffix=".csv")
+            self.grid_filename = tmpfile.name
+            data = self.dataFromTable(self.tblParams)
+            t = time.localtime(time.time())
+            time_str = time.strftime("%b %d %H:%M of %Y", t)
+            details = "File Generated by SasView " 
+            details += "on %s.\n" % time_str
+            self.writeBatchToFile(data=data, tmpfile=tmpfile, details=details)
+            tmpfile.close()
+
+        try:
+            from win32com.client import Dispatch
+            excel_app = Dispatch('Excel.Application')
+            excel_app.Workbooks.Open(self.grid_filename)
+            excel_app.Visible = 1
+        except Exception as ex:
+            msg = "Error occured when calling Excel.\n"
+            msg += ex
+            self.parent.communicate.statusBarUpdateSignal.emit(msg)
+
+    def actionSaveFile(self):
+        """
+        Generate a .csv file and dump it do disk
+        """
+        t = time.localtime(time.time())
+        time_str = time.strftime("%b %d %H %M of %Y", t)
+        default_name = "Batch_Fitting_"+time_str+".csv"
+
+        wildcard = "CSV files (*.csv);;"
+        kwargs = {
+            'caption'   : 'Save As',
+            'directory' : default_name,
+            'filter'    : wildcard,
+            'parent'    : None,
+        }
+        # Query user for filename.
+        filename_tuple = QtWidgets.QFileDialog.getSaveFileName(**kwargs)
+        filename = filename_tuple[0]
+
+        # User cancelled.
+        if not filename:
+            return
+        data = self.dataFromTable(self.tblParams)
+        details = "File generated by SasView\n"
+        with open(filename, 'w') as csv_file:
+            self.writeBatchToFile(data=data, tmpfile=csv_file, details=details)
+
+    def setupTableFromCSV(self, csv_data):
+        """
+        Create tablewidget items and show them, based on params
+        """
+        # Clear existing display
+        self.tblParams.clear()
+        # headers
+        param_list = csv_data[1].rstrip().split(',')
+        for i, param in enumerate(param_list):
+            self.tblParams.setHorizontalHeaderItem(i, QtWidgets.QTableWidgetItem(param))
+
+        # first - Chi2 and data filename
+        for i_row, row in enumerate(csv_data[2:]):
+            for i_col, col in enumerate(row.rstrip().split(',')):
+                self.tblParams.setItem(i_row, i_col, QtWidgets.QTableWidgetItem(col))
+
+        self.tblParams.resizeColumnsToContents()
+
+    def setupTable(self, data):
+        """
+        Create tablewidget items and show them, based on params
+        """
+        # headers
+        model = data[0][0]
+        param_list = [m for m in model.model.params.keys() if ":" not in m]
+
+        # Check if 2D model. If not, remove theta/phi
+
+        rows = len(data)
+        columns = len(param_list)
+        self.tblParams.setColumnCount(columns+2)
+        self.tblParams.setRowCount(rows)
+
+        param_list.insert(0, "Data")
+        param_list.insert(0, "Chi2")
+        for i, param in enumerate(param_list):
+            self.tblParams.setHorizontalHeaderItem(i, QtWidgets.QTableWidgetItem(param))
+
+        # first - Chi2 and data filename
+        for i_row, row in enumerate(data):
+            # each row corresponds to a single fit
+            chi2 = row[0].fitness
+            filename = ""
+            if hasattr(row[0].data, "sas_data"):
+                filename = row[0].data.sas_data.filename
+            self.tblParams.setItem(i_row, 0, QtWidgets.QTableWidgetItem(GuiUtils.formatNumber(chi2, high=True)))
+            self.tblParams.setItem(i_row, 1, QtWidgets.QTableWidgetItem(str(filename)))
+            # Now, all the parameters
+            for i_col, param in enumerate(param_list[2:]):
+                if param in row[0].param_list:
+                    # parameter is on the to-optimize list - get the optimized value
+                    par_value = row[0].pvec[row[0].param_list.index(param)]
+                    # should we parse out errors here and store them?
+                else:
+                    # parameter was not varied
+                    par_value = row[0].model.params[param]
+                self.tblParams.setItem(i_row, i_col+2, QtWidgets.QTableWidgetItem(
+                    GuiUtils.formatNumber(par_value, high=True)))
+
+        self.tblParams.resizeColumnsToContents()
+
+    def writeBatchToFile(self, data, tmpfile, details=""):
+        """
+        Helper to write result from batch into cvs file
+        """
+        name = tmpfile.name
+        if data is None or name is None or name.strip() == "":
+            return
+        _, ext = os.path.splitext(name)
+        separator = "\t"
+        if ext.lower() == ".csv":
+            separator = ","
+        tmpfile.write(details)
+        for col_name in data.keys():
+            tmpfile.write(col_name)
+            tmpfile.write(separator)
+        tmpfile.write('\n')
+        max_list = [len(value) for value in data.values()]
+        if len(max_list) == 0:
+            return
+        max_index = max(max_list)
+        index = 0
+        while index < max_index:
+            for value_list in data.values():
+                if index < len(value_list):
+                    tmpfile.write(str(value_list[index]))
+                    tmpfile.write(separator)
+                else:
+                    tmpfile.write('')
+                    tmpfile.write(separator)
+            tmpfile.write('\n')
+            index += 1
+
Index: src/sas/qtgui/Utilities/GuiUtils.py
===================================================================
--- src/sas/qtgui/Utilities/GuiUtils.py	(revision 63319b0e1635f70a44346bc676740b77b0aa7102)
+++ src/sas/qtgui/Utilities/GuiUtils.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -213,4 +213,7 @@
     plotRequestedSignal = QtCore.pyqtSignal(list)
 
+    # Plot from file names
+    plotFromFilenameSignal = QtCore.pyqtSignal(str)
+
     # Plot update requested from a perspective
     plotUpdateSignal = QtCore.pyqtSignal(list)
@@ -236,4 +239,7 @@
     # Send result of Data Operation Utility panel to Data Explorer
     updateModelFromDataOperationPanelSignal = QtCore.pyqtSignal(QtGui.QStandardItem, dict)
+
+    # Notify about a new custom plugin being written/deleted/modified
+    customModelDirectoryChanged = QtCore.pyqtSignal()
 
 def updateModelItemWithPlot(item, update_data, name=""):
@@ -872,4 +878,27 @@
         raise TypeError
 
+def findNextFilename(filename, directory):
+    """
+    Finds the next available (non-existing) name for 'filename' in 'directory'.
+    plugin.py -> plugin (n).py  - for first 'n' for which the file doesn't exist
+    """
+    basename, ext = os.path.splitext(filename)
+    # limit the number of copies
+    MAX_FILENAMES = 1000
+    # Start with (1)
+    number_ext = 1
+    proposed_filename = ""
+    found_filename = False
+    # Find the next available filename or exit if too many copies
+    while not found_filename or number_ext > MAX_FILENAMES:
+        proposed_filename = basename + " ("+str(number_ext)+")" + ext
+        if os.path.exists(os.path.join(directory, proposed_filename)):
+            number_ext += 1
+        else:
+            found_filename = True
+
+    return proposed_filename
+
+
 class DoubleValidator(QtGui.QDoubleValidator):
     """
Index: src/sas/qtgui/Utilities/LocalConfig.py
===================================================================
--- src/sas/qtgui/Utilities/LocalConfig.py	(revision c3eb8580d07c18adae8887b6238dace7905f23e1)
+++ src/sas/qtgui/Utilities/LocalConfig.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -141,4 +141,8 @@
 # Time out for updating sasview
 UPDATE_TIMEOUT = 2
+
+# Logging levels to disable, if any
+DISABLE_LOGGING = logging.DEBUG
+
 def printEVT(message):
     """
Index: src/sas/qtgui/Utilities/ModelEditor.py
===================================================================
--- src/sas/qtgui/Utilities/ModelEditor.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/ModelEditor.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,60 @@
+# global
+import sys
+import os
+import types
+import webbrowser
+
+from PyQt5 import QtCore
+from PyQt5 import QtGui
+from PyQt5 import QtWidgets
+
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+from sas.qtgui.Utilities.PythonSyntax import PythonHighlighter
+
+from sas.qtgui.Utilities.UI.ModelEditor import Ui_ModelEditor
+
+class ModelEditor(QtWidgets.QDialog, Ui_ModelEditor):
+    """
+    Class describing the "advanced" model editor.
+    This is a simple text browser allowing for editing python and
+    supporting simple highlighting.
+    """
+    modelModified = QtCore.pyqtSignal()
+    def __init__(self, parent=None):
+        super(ModelEditor, self).__init__(parent)
+        self.setupUi(self)
+
+        self.setupWidgets()
+
+        self.addSignals()
+
+    def setupWidgets(self):
+        """
+        Set up dialog widgets.
+        Here - just the highlighter connected to the text edit.
+        """
+        self.highlight = PythonHighlighter(self.txtEditor.document())
+
+    def addSignals(self):
+        """
+        Respond to signals in the widget
+        """
+        self.txtEditor.textChanged.connect(self.onEdit)
+
+    def onEdit(self):
+        """
+        Respond to changes in the text browser.
+        """
+        # We have edited the model - notify the parent.
+        if self.txtEditor.toPlainText() != "":
+            self.modelModified.emit()
+        pass
+
+    def getModel(self):
+        """
+        Return the current model, as displayed in the window
+        """
+        model = {'text':self.txtEditor.toPlainText()}
+        model['filename'] = ""
+        return model
+
Index: src/sas/qtgui/Utilities/PluginDefinition.py
===================================================================
--- src/sas/qtgui/Utilities/PluginDefinition.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/PluginDefinition.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,174 @@
+# global
+import sys
+import os
+import logging 
+
+from PyQt5 import QtCore
+from PyQt5 import QtGui
+from PyQt5 import QtWidgets
+
+from sas.qtgui.Utilities.UI.PluginDefinitionUI import Ui_PluginDefinition
+from sas.qtgui.Utilities.PythonSyntax import PythonHighlighter
+
+# txtName
+# txtDescription
+# chkOverwrite
+# tblParams
+# tblParamsPD
+# txtFunction
+
+class PluginDefinition(QtWidgets.QDialog, Ui_PluginDefinition):
+    """
+    Class describing the "simple" plugin editor.
+    This is a simple series of widgets allowing for specifying
+    model form and parameters.
+    """
+    modelModified = QtCore.pyqtSignal()
+    def __init__(self, parent=None):
+        super(PluginDefinition, self).__init__(parent)
+        self.setupUi(self)
+
+        # globals
+        self.initializeModel()
+        # internal representation of the parameter list
+        # {<row>: (<parameter>, <value>)}
+        self.parameter_dict = {}
+        self.pd_parameter_dict = {}
+
+        # Initialize signals
+        self.addSignals()
+
+        # Initialize widgets
+        self.addWidgets()
+
+    def addWidgets(self):
+        """
+        Initialize various widgets in the dialog
+        """
+        # Set the tooltip
+        hint_function = "#Example:\n\n"
+        hint_function += "if x <= 0:\n"
+        hint_function += "    y = A + B\n"
+        hint_function += "else:\n"
+        hint_function += "    y = A + B * cos(2 * pi * x)\n"
+        hint_function += "return y\n"
+        self.txtFunction.setToolTip(hint_function)
+        # Initial text in the function table
+        text = \
+"""y = x
+
+return y
+"""
+        self.txtFunction.insertPlainText(text)
+
+        # Validators
+        #rx = QtCore.QRegExp(r'^[\w,\s-]+$')
+        #rx = QtCore.QRegExp("[a-z-A-Z_]+")
+        rx = QtCore.QRegExp("^[A-Za-z0-9_]*$")
+
+        txt_validator = QtGui.QRegExpValidator(rx)
+        self.txtName.setValidator(txt_validator)
+        self.highlight = PythonHighlighter(self.txtFunction.document())        
+
+    def initializeModel(self):
+        """
+        Define the dictionary for internal data representation
+        """
+        # Define the keys
+        self.model = {
+            'filename':'',
+            'overwrite':False,
+            'description':'',
+            'parameters':{},
+            'pd_parameters':{},
+            'text':''}
+
+    def addSignals(self):
+        """
+        Define slots for widget signals
+        """
+        self.txtName.editingFinished.connect(self.onPluginNameChanged)
+        self.txtDescription.editingFinished.connect(self.onDescriptionChanged)
+        self.tblParams.cellChanged.connect(self.onParamsChanged)
+        self.tblParamsPD.cellChanged.connect(self.onParamsPDChanged)
+        # QTextEdit doesn't have a signal for edit finish, so we respond to text changed.
+        # Possibly a slight overkill.
+        self.txtFunction.textChanged.connect(self.onFunctionChanged)
+        self.chkOverwrite.toggled.connect(self.onOverwrite)
+
+    def onPluginNameChanged(self):
+        """
+        Respond to changes in plugin name
+        """
+        self.model['filename'] = self.txtName.text()
+        self.modelModified.emit()
+
+    def onDescriptionChanged(self):
+        """
+        Respond to changes in plugin description
+        """
+        self.model['description'] = self.txtDescription.text()
+        self.modelModified.emit()
+
+    def onParamsChanged(self, row, column):
+        """
+        Respond to changes in non-polydisperse parameter table
+        """
+        param = value = None
+        if self.tblParams.item(row, 0):
+            param = self.tblParams.item(row, 0).data(0)
+        if self.tblParams.item(row, 1):
+            value = self.tblParams.item(row, 1).data(0)
+
+        # If modified, just update the dict
+        self.parameter_dict[row] = (param, value)
+        self.model['parameters'] = self.parameter_dict
+
+        # Check if the update was Value for last row. If so, add a new row
+        if column == 1 and row == self.tblParams.rowCount()-1:
+            # Add a row
+            self.tblParams.insertRow(self.tblParams.rowCount())
+        self.modelModified.emit()
+
+    def onParamsPDChanged(self, row, column):
+        """
+        Respond to changes in non-polydisperse parameter table
+        """
+        param = value = None
+        if self.tblParamsPD.item(row, 0):
+            param = self.tblParamsPD.item(row, 0).data(0)
+        if self.tblParamsPD.item(row, 1):
+            value = self.tblParamsPD.item(row, 1).data(0)
+
+        # If modified, just update the dict
+        self.pd_parameter_dict[row] = (param, value)
+        self.model['pd_parameters'] = self.pd_parameter_dict
+
+        # Check if the update was Value for last row. If so, add a new row
+        if column == 1 and row == self.tblParamsPD.rowCount()-1:
+            # Add a row
+            self.tblParamsPD.insertRow(self.tblParamsPD.rowCount())
+        self.modelModified.emit()
+
+
+    def onFunctionChanged(self):
+        """
+        Respond to changes in function body
+        """
+        # keep in mind that this is called every time the text changes.
+        # mind the performance!
+        self.model['text'] = self.txtFunction.toPlainText().lstrip().rstrip()
+        self.modelModified.emit()
+
+    def onOverwrite(self):
+        """
+        Respond to change in file overwrite checkbox
+        """
+        self.model['overwrite'] = self.chkOverwrite.isChecked()
+        self.modelModified.emit()
+
+    def getModel(self):
+        """
+        Return the current plugin model
+        """
+        return self.model
Index: src/sas/qtgui/Utilities/PluginManager.py
===================================================================
--- src/sas/qtgui/Utilities/PluginManager.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/PluginManager.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,144 @@
+# global
+import sys
+import os
+import logging 
+from shutil import copyfile
+
+from PyQt5 import QtCore
+from PyQt5 import QtGui
+from PyQt5 import QtWidgets
+
+from sas.sascalc.fit import models
+from sas.qtgui.Perspectives.Fitting import ModelUtilities
+from sas.qtgui.Utilities.TabbedModelEditor import TabbedModelEditor
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+
+from sas.qtgui.Utilities.UI.PluginManagerUI import Ui_PluginManagerUI
+
+
+class PluginManager(QtWidgets.QDialog, Ui_PluginManagerUI):
+    """
+    Class describing the model plugin manager.
+    This is a simple list widget allowing for viewing/adding/deleting custom models.
+    """
+    def __init__(self, parent=None):
+        super(PluginManager, self).__init__()
+        self.setupUi(self)
+
+        self.parent = parent
+        self.cmdDelete.setEnabled(False)
+        self.cmdEdit.setEnabled(False)
+        self.cmdDuplicate.setEnabled(False)
+
+        # globals
+        self.readModels()
+
+        # internal representation of the parameter list
+        # {<row>: (<parameter>, <value>)}
+        self.plugin_dict = {}
+
+        # Initialize signals
+        self.addSignals()
+
+    def readModels(self):
+        """
+        Read in custom models from the default location
+        """
+        self.lstModels.clear()
+        plugins = ModelUtilities._find_models()
+        models = list(plugins.keys())
+        self.lstModels.addItems(models)
+
+    def addSignals(self):
+        """
+        Define slots for widget signals
+        """
+        self.cmdOK.clicked.connect(self.accept)
+        self.cmdDelete.clicked.connect(self.onDelete)
+        self.cmdAdd.clicked.connect(self.onAdd)
+        self.cmdDuplicate.clicked.connect(self.onDuplicate)
+        self.cmdEdit.clicked.connect(self.onEdit)
+        self.cmdHelp.clicked.connect(self.onHelp)
+        self.lstModels.selectionModel().selectionChanged.connect(self.onSelectionChanged)
+        self.parent.communicate.customModelDirectoryChanged.connect(self.readModels)
+
+    def onSelectionChanged(self):
+        """
+        Respond to row selection
+        """
+        rows = len(self.lstModels.selectionModel().selectedRows())
+        self.cmdDelete.setEnabled(rows>0)
+        self.cmdEdit.setEnabled(rows==1)
+        self.cmdDuplicate.setEnabled(rows>0)
+
+    def onDelete(self):
+        """
+        Remove the file containing the selected plugin
+        """
+        plugins_to_delete = [s.data() for s in self.lstModels.selectionModel().selectedRows()]
+
+        delete_msg = "Are you sure you want to remove the selected plugins?"
+        reply = QtWidgets.QMessageBox.question(
+            self,
+            'Warning',
+            delete_msg,
+            QtWidgets.QMessageBox.Yes,
+            QtWidgets.QMessageBox.No)
+
+        # Exit if no
+        if reply == QtWidgets.QMessageBox.No:
+            return
+
+        for plugin in plugins_to_delete:
+            name = os.path.join(ModelUtilities.find_plugins_dir(), plugin + ".py")
+            os.remove(name)
+
+        self.parent.communicate.customModelDirectoryChanged.emit()
+
+    def onAdd(self):
+        """
+        Show the add new model dialog
+        """
+        self.add_widget = TabbedModelEditor(parent=self.parent)
+        self.add_widget.show()
+
+    def onDuplicate(self):
+        """
+        Creates a copy of the selected model(s)
+        """
+
+        plugins_to_copy = [s.data() for s in self.lstModels.selectionModel().selectedRows()]
+        plugin_dir = ModelUtilities.find_plugins_dir()
+        for plugin in plugins_to_copy:
+            src_filename = plugin + ".py"
+            src_file = os.path.join(plugin_dir, src_filename)
+            dst_filename = GuiUtils.findNextFilename(src_filename, plugin_dir)
+            if not dst_filename:
+                logging.error("Could not find appropriate filename for "+src_file)
+            dst_file = os.path.join(plugin_dir, dst_filename)
+            copyfile(src_file, dst_file)
+            self.parent.communicate.customModelDirectoryChanged.emit()
+
+    def onEdit(self):
+        """
+        Show the edit existing model dialog
+        """
+        plugin_location = models.find_plugins_dir()
+        # GUI assured only one row selected. Pick up the only element in list.
+        try:
+            model_to_edit = self.lstModels.selectionModel().selectedRows()[0].data()
+        except Exception:
+            # Something wrong with model, return
+            return
+        name = os.path.join(plugin_location, model_to_edit + ".py")
+        self.edit_widget = TabbedModelEditor(parent=self.parent, edit_only=True)
+        self.edit_widget.loadFile(name)
+        self.edit_widget.show()
+
+    def onHelp(self):
+        """
+        Show the help page in the default browser
+        """
+        location = "/user/sasgui/perspectives/fitting/fitting_help.html#new-plugin-model"
+        self.parent.showHelp(location)
+                
Index: src/sas/qtgui/Utilities/PythonSyntax.py
===================================================================
--- src/sas/qtgui/Utilities/PythonSyntax.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/PythonSyntax.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,190 @@
+import sys
+
+from PyQt5.QtCore import QRegExp
+from PyQt5.QtGui import QColor, QTextCharFormat, QFont, QSyntaxHighlighter
+
+def format(color, style=''):
+    """Return a QTextCharFormat with the given attributes.
+    """
+    _color = QColor()
+    _color.setNamedColor(color)
+
+    _format = QTextCharFormat()
+    _format.setForeground(_color)
+    if 'bold' in style:
+        _format.setFontWeight(QFont.Bold)
+    if 'italic' in style:
+        _format.setFontItalic(True)
+
+    return _format
+
+
+# Syntax styles that can be shared by all languages
+STYLES = {
+    'keyword': format('blue'),
+    'operator': format('red'),
+    'brace': format('darkGray'),
+    'defclass': format('black', 'bold'),
+    'string': format('magenta'),
+    'string2': format('darkMagenta'),
+    'comment': format('darkGreen', 'italic'),
+    'self': format('black', 'italic'),
+    'numbers': format('brown'),
+}
+
+
+class PythonHighlighter (QSyntaxHighlighter):
+    """Syntax highlighter for the Python language.
+    """
+    # Python keywords
+    keywords = [
+        'and', 'assert', 'break', 'class', 'continue', 'def',
+        'del', 'elif', 'else', 'except', 'exec', 'finally',
+        'for', 'from', 'global', 'if', 'import', 'in',
+        'is', 'lambda', 'not', 'or', 'pass', 'print',
+        'raise', 'return', 'try', 'while', 'yield',
+        'None', 'True', 'False',
+    ]
+
+    # Python operators
+    operators = [
+        '=',
+        # Comparison
+        '==', '!=', '<', '<=', '>', '>=',
+        # Arithmetic
+        '\+', '-', '\*', '/', '//', '\%', '\*\*',
+        # In-place
+        '\+=', '-=', '\*=', '/=', '\%=',
+        # Bitwise
+        '\^', '\|', '\&', '\~', '>>', '<<',
+    ]
+
+    # Python braces
+    braces = [
+        '\{', '\}', '\(', '\)', '\[', '\]',
+    ]
+    def __init__(self, document):
+        QSyntaxHighlighter.__init__(self, document)
+
+        # Multi-line strings (expression, flag, style)
+        # FIXME: The triple-quotes in these two lines will mess up the
+        # syntax highlighting from this point onward
+        self.tri_single = (QRegExp("'''"), 1, STYLES['string2'])
+        self.tri_double = (QRegExp('"""'), 2, STYLES['string2'])
+
+        rules = []
+
+        # Keyword, operator, and brace rules
+        rules += [(r'\b%s\b' % w, 0, STYLES['keyword'])
+            for w in PythonHighlighter.keywords]
+        rules += [(r'%s' % o, 0, STYLES['operator'])
+            for o in PythonHighlighter.operators]
+        rules += [(r'%s' % b, 0, STYLES['brace'])
+            for b in PythonHighlighter.braces]
+
+        # All other rules
+        rules += [
+            # 'self'
+            (r'\bself\b', 0, STYLES['self']),
+
+            # Double-quoted string, possibly containing escape sequences
+            (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']),
+            # Single-quoted string, possibly containing escape sequences
+            (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']),
+
+            # 'def' followed by an identifier
+            (r'\bdef\b\s*(\w+)', 1, STYLES['defclass']),
+            # 'class' followed by an identifier
+            (r'\bclass\b\s*(\w+)', 1, STYLES['defclass']),
+
+            # From '#' until a newline
+            (r'#[^\n]*', 0, STYLES['comment']),
+
+            # Numeric literals
+            (r'\b[+-]?[0-9]+[lL]?\b', 0, STYLES['numbers']),
+            (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, STYLES['numbers']),
+            (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, STYLES['numbers']),
+        ]
+
+        # Build a QRegExp for each pattern
+        self.rules = [(QRegExp(pat), index, fmt)
+            for (pat, index, fmt) in rules]
+
+
+    def highlightBlock(self, text):
+        """Apply syntax highlighting to the given block of text.
+        """
+        # Do other syntax formatting
+        for expression, nth, format in self.rules:
+            index = expression.indexIn(text, 0)
+
+            while index >= 0:
+                # We actually want the index of the nth match
+                index = expression.pos(nth)
+                length = len(expression.cap(nth))
+                self.setFormat(index, length, format)
+                index = expression.indexIn(text, index + length)
+
+        self.setCurrentBlockState(0)
+
+        # Do multi-line strings
+        in_multiline = self.match_multiline(text, *self.tri_single)
+        if not in_multiline:
+            in_multiline = self.match_multiline(text, *self.tri_double)
+
+
+    def match_multiline(self, text, delimiter, in_state, style):
+        """Do highlighting of multi-line strings. ``delimiter`` should be a
+        ``QRegExp`` for triple-single-quotes or triple-double-quotes, and
+        ``in_state`` should be a unique integer to represent the corresponding
+        state changes when inside those strings. Returns True if we're still
+        inside a multi-line string when this function is finished.
+        """
+        # If inside triple-single quotes, start at 0
+        if self.previousBlockState() == in_state:
+            start = 0
+            add = 0
+        # Otherwise, look for the delimiter on this line
+        else:
+            start = delimiter.indexIn(text)
+            # Move past this match
+            add = delimiter.matchedLength()
+
+        # As long as there's a delimiter match on this line...
+        while start >= 0:
+            # Look for the ending delimiter
+            end = delimiter.indexIn(text, start + add)
+            # Ending delimiter on this line?
+            if end >= add:
+                length = end - start + add + delimiter.matchedLength()
+                self.setCurrentBlockState(0)
+            # No; multi-line string
+            else:
+                self.setCurrentBlockState(in_state)
+                length = len(text) - start + add
+            # Apply formatting
+            self.setFormat(start, length, style)
+            # Look for the next match
+            start = delimiter.indexIn(text, start + length)
+
+        # Return True if still inside a multi-line string, False otherwise
+        if self.currentBlockState() == in_state:
+            return True
+        else:
+            return False
+
+if __name__ == '__main__':
+    import sys
+
+    from PyQt5 import QtWidgets
+
+    app = QtWidgets.QApplication([])
+    editor = QtWidgets.QPlainTextEdit()
+    highlight = PythonHighlighter(editor.document())
+    editor.show()
+
+    # Load syntax.py into the editor for demo purposes
+    infile = open('PythonSyntax.py', 'r')
+    editor.setPlainText(infile.read())
+
+    app.exec_()
Index: src/sas/qtgui/Utilities/TabbedModelEditor.py
===================================================================
--- src/sas/qtgui/Utilities/TabbedModelEditor.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/TabbedModelEditor.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,507 @@
+# global
+import sys
+import os
+import types
+import datetime
+import numpy as np
+import webbrowser
+import logging
+
+from PyQt5 import QtCore
+from PyQt5 import QtGui
+from PyQt5 import QtWidgets
+
+from sas.sascalc.fit import models
+
+from sas.qtgui.Utilities.UI.TabbedModelEditor import Ui_TabbedModelEditor
+from sas.qtgui.Utilities.PluginDefinition import PluginDefinition
+from sas.qtgui.Utilities.ModelEditor import ModelEditor
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+
+class TabbedModelEditor(QtWidgets.QDialog, Ui_TabbedModelEditor):
+    """
+    Model editor "container" class describing interaction between
+    plugin definition widget and model editor widget.
+    Once the model is defined, it can be saved as a plugin.
+    """
+    # Signals for intertab communication plugin -> editor
+    def __init__(self, parent=None, edit_only=False):
+        super(TabbedModelEditor, self).__init__()
+
+        self.parent = parent
+
+        self.setupUi(self)
+
+        # globals
+        self.filename = ""
+        self.window_title = self.windowTitle()
+        self.edit_only = edit_only
+        self.is_modified = False
+
+        self.addWidgets()
+
+        self.addSignals()
+
+    def addWidgets(self):
+        """
+        Populate tabs with widgets
+        """
+        # Set up widget enablement/visibility
+        self.cmdLoad.setVisible(self.edit_only)
+
+        # Add tabs
+        # Plugin definition widget
+        self.plugin_widget = PluginDefinition(self)
+        self.tabWidget.addTab(self.plugin_widget, "Plugin Definition")
+        self.setPluginActive(True)
+
+        self.editor_widget = ModelEditor(self)
+        # Initially, nothing in the editor
+        self.editor_widget.setEnabled(False)
+        self.tabWidget.addTab(self.editor_widget, "Model editor")
+        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
+
+        if self.edit_only:
+            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setText("Save")
+            # Hide signals from the plugin widget
+            self.plugin_widget.blockSignals(True)
+            # and hide the tab/widget itself
+            self.tabWidget.removeTab(0)
+
+    def addSignals(self):
+        """
+        Define slots for common widget signals
+        """
+        # buttons
+        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).clicked.connect(self.onApply)
+        self.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel).clicked.connect(self.onCancel)
+        self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
+        self.cmdLoad.clicked.connect(self.onLoad)
+        # signals from tabs
+        self.editor_widget.modelModified.connect(self.editorModelModified)
+        self.plugin_widget.modelModified.connect(self.pluginModelModified)
+
+    def setPluginActive(self, is_active=True):
+        """
+        Enablement control for all the controls on the simple plugin editor
+        """
+        self.plugin_widget.setEnabled(is_active)
+
+    def closeEvent(self, event):
+        """
+        Overwrite the close even to assure intent
+        """
+        if self.is_modified:
+            ret = self.onModifiedExit()
+            if ret == QtWidgets.QMessageBox.Cancel:
+                return
+            elif ret == QtWidgets.QMessageBox.Save:
+                self.updateFromEditor()
+        event.accept()
+
+    def onLoad(self):
+        """
+        Loads a model plugin file
+        """
+        plugin_location = models.find_plugins_dir()
+        filename = QtWidgets.QFileDialog.getOpenFileName(
+                                        self,
+                                        'Open Plugin',
+                                        plugin_location,
+                                        'SasView Plugin Model (*.py)',
+                                        None,
+                                        QtWidgets.QFileDialog.DontUseNativeDialog)[0]
+
+        # Load the file
+        if not filename:
+            logging.info("No data file chosen.")
+            return
+
+        self.loadFile(filename)
+
+    def loadFile(self, filename):
+        """
+        Performs the load operation and updates the view
+        """
+        self.editor_widget.blockSignals(True)
+        with open(filename, 'r') as plugin:
+            self.editor_widget.txtEditor.setPlainText(plugin.read())
+        self.editor_widget.setEnabled(True)
+        self.editor_widget.blockSignals(False)
+        self.filename, _ = os.path.splitext(os.path.basename(filename))
+
+        self.setWindowTitle(self.window_title + " - " + self.filename)
+
+    def onModifiedExit(self):
+        msg_box = QtWidgets.QMessageBox(self)
+        msg_box.setWindowTitle("SasView Model Editor")
+        msg_box.setText("The document has been modified.")
+        msg_box.setInformativeText("Do you want to save your changes?")
+        msg_box.setStandardButtons(QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel)
+        msg_box.setDefaultButton(QtWidgets.QMessageBox.Save)
+        return msg_box.exec()
+
+    def onCancel(self):
+        """
+        Accept if document not modified, confirm intent otherwise.
+        """
+        if self.is_modified:
+            ret = self.onModifiedExit()
+            if ret == QtWidgets.QMessageBox.Cancel:
+                return
+            elif ret == QtWidgets.QMessageBox.Save:
+                self.updateFromEditor()
+        self.reject()
+
+    def onApply(self):
+        """
+        Write the plugin and update the model editor if plugin editor open
+        Write/overwrite the plugin if model editor open
+        """
+        if isinstance(self.tabWidget.currentWidget(), PluginDefinition):
+            self.updateFromPlugin()
+        else:
+            self.updateFromEditor()
+        self.is_modified = False
+
+    def editorModelModified(self):
+        """
+        User modified the model in the Model Editor.
+        Disable the plugin editor and show that the model is changed.
+        """
+        self.setTabEdited(True)
+        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
+        self.is_modified = True
+
+    def pluginModelModified(self):
+        """
+        User modified the model in the Plugin Editor.
+        Show that the model is changed.
+        """
+        # Ensure plugin name is non-empty
+        model = self.getModel()
+        if 'filename' in model and model['filename']:
+            self.setWindowTitle(self.window_title + " - " + model['filename'])
+            self.setTabEdited(True)
+            # Enable editor
+            self.editor_widget.setEnabled(True)
+            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
+            self.is_modified = True
+        else:
+            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
+
+    def setTabEdited(self, is_edited):
+        """
+        Change the widget name to indicate unsaved state
+        Unsaved state: add "*" to filename display
+        saved state: remove "*" from filename display
+        """
+        current_text = self.windowTitle()
+
+        if is_edited:
+            if current_text[-1] != "*":
+                current_text += "*"
+        else:
+            if current_text[-1] == "*":
+                current_text = current_text[:-1]
+        self.setWindowTitle(current_text)
+
+    def updateFromPlugin(self):
+        """
+        Write the plugin and update the model editor
+        """
+        # get current model
+        model = self.getModel()
+        if 'filename' not in model: return
+
+        # get required filename
+        filename = model['filename']
+
+        # check if file exists
+        plugin_location = models.find_plugins_dir()
+        full_path = os.path.join(plugin_location, filename)
+        if os.path.splitext(full_path)[1] != ".py":
+            full_path += ".py"
+
+        # Update the global path definition
+        self.filename = full_path
+
+        if not self.canWriteModel(model, full_path):
+            return
+
+        # generate the model representation as string
+        model_str = self.generateModel(model, full_path)
+        self.writeFile(full_path, model_str)
+        # TODO:
+        # Temporarily disable model check -
+        # unittest.suite() gives weird results in qt5.
+        # needs investigating
+        #try:
+        #    _, msg = self.checkModel(full_path), None
+        #except Exception as ex:
+        #    result, msg = None, "Error building model: "+ str(ex)
+
+        # Update the editor here.
+        # Simple string forced into control.
+        self.editor_widget.blockSignals(True)
+        self.editor_widget.txtEditor.setPlainText(model_str)
+        self.editor_widget.blockSignals(False)
+
+        # Set the widget title
+        self.setTabEdited(False)
+
+        # Notify listeners
+        self.parent.communicate.customModelDirectoryChanged.emit()
+
+    def updateFromEditor(self):
+        """
+        Save the current state of the Model Editor
+        """
+        # make sure we have the file handly ready
+        assert(self.filename != "")
+        # Retrieve model string
+        model_str = self.getModel()['text']
+        # Save the file
+        self.writeFile(self.filename, model_str)
+        # Update the tab title
+        self.setTabEdited(False)
+        
+    def canWriteModel(self, model=None, full_path=""):
+        """
+        Determine if the current plugin can be written to file
+        """
+        assert(isinstance(model, dict))
+        assert(full_path!="")
+
+        # Make sure we can overwrite the file if it exists
+        if os.path.isfile(full_path):
+            # can we overwrite it?
+            if not model['overwrite']:
+                # notify the viewer
+                msg = "Plugin with specified name already exists.\n"
+                msg += "Please specify different filename or allow file overwrite."
+                QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
+                # Don't accept but return
+                return False
+        # Update model editor if plugin definition changed
+        func_str = model['text']
+        msg = None
+        if func_str:
+            if 'return' not in func_str:
+                msg = "Error: The func(x) must 'return' a value at least.\n"
+                msg += "For example: \n\nreturn 2*x"
+        else:
+            msg = 'Error: Function is not defined.'
+        if msg is not None:
+            QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
+            return False
+        return True
+
+    def onHelp(self):
+        """
+        Bring up the Model Editor Documentation whenever
+        the HELP button is clicked.
+        Calls Documentation Window with the path of the location within the
+        documentation tree (after /doc/ ....".
+        """
+        location = "/user/sasgui/perspectives/fitting/plugin.html"
+        self.parent.showHelp(location)
+
+    def getModel(self):
+        """
+        Retrieves plugin model from the currently open tab
+        """
+        return self.tabWidget.currentWidget().getModel()
+
+    def writeFile(self, fname, model_str=""):
+        """
+        Write model content to file "fname"
+        """
+        with open(fname, 'w') as out_f:
+            out_f.write(model_str)
+
+    def generateModel(self, model, fname):
+        """
+        generate model from the current plugin state
+        """
+        name = model['filename']
+        desc_str = model['description']
+        param_str = self.strFromParamDict(model['parameters'])
+        pd_param_str = self.strFromParamDict(model['pd_parameters'])
+        func_str = model['text']
+        model_text = CUSTOM_TEMPLATE % {
+            'name': name,
+            'title': 'User model for ' + name,
+            'description': desc_str,
+            'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'),
+        }
+
+        # Write out parameters
+        param_names = []    # to store parameter names
+        pd_params = []
+        model_text += 'parameters = [ \n'
+        model_text += '#   ["name", "units", default, [lower, upper], "type", "description"],\n'
+        for pname, pvalue, desc in self.getParamHelper(param_str):
+            param_names.append(pname)
+            model_text += "    ['%s', '', %s, [-inf, inf], '', '%s'],\n" % (pname, pvalue, desc)
+        for pname, pvalue, desc in self.getParamHelper(pd_param_str):
+            param_names.append(pname)
+            pd_params.append(pname)
+            model_text += "    ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc)
+        model_text += '    ]\n'
+
+        # Write out function definition
+        model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names)
+        model_text += '    """Absolute scattering"""\n'
+        if "scipy." in func_str:
+            model_text +="    import scipy\n"
+        if "numpy." in func_str:
+            model_text +="    import numpy\n"
+        if "np." in func_str:
+            model_text +="    import numpy as np\n"
+        for func_line in func_str.split('\n'):
+                model_text +='%s%s\n' % ("    ", func_line)
+        model_text +='## uncomment the following if Iq works for vector x\n'
+        model_text +='#Iq.vectorized = True\n'
+
+        # If polydisperse, create place holders for form_volume, ER and VR
+        if pd_params:
+            model_text +="\n"
+            model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)}
+
+        # Create place holder for Iqxy
+        model_text +="\n"
+        model_text +='#def Iqxy(%s):\n' % ', '.join(["x", "y"] + param_names)
+        model_text +='#    """Absolute scattering of oriented particles."""\n'
+        model_text +='#    ...\n'
+        model_text +='#    return oriented_form(x, y, args)\n'
+        model_text +='## uncomment the following if Iqxy works for vector x, y\n'
+        model_text +='#Iqxy.vectorized = True\n'
+
+        return model_text
+
+    def checkModel(self, path):
+        """
+        Check that the model save in file 'path' can run.
+        """
+        # try running the model
+        from sasmodels.sasview_model import load_custom_model
+        Model = load_custom_model(path)
+        model = Model()
+        q =  np.array([0.01, 0.1])
+        Iq = model.evalDistribution(q)
+        qx, qy =  np.array([0.01, 0.01]), np.array([0.1, 0.1])
+        Iqxy = model.evalDistribution([qx, qy])
+
+        # check the model's unit tests run
+        from sasmodels.model_test import run_one
+        result = run_one(path)
+
+        return result
+
+    def getParamHelper(self, param_str):
+        """
+        yield a sequence of name, value pairs for the parameters in param_str
+
+        Parameters can be defined by one per line by name=value, or multiple
+        on the same line by separating the pairs by semicolon or comma.  The
+        value is optional and defaults to "1.0".
+        """
+        for line in param_str.replace(';', ',').split('\n'):
+            for item in line.split(','):
+                defn, desc = item.split('#', 1) if '#' in item else (item, '')
+                name, value = defn.split('=', 1) if '=' in defn else (defn, '1.0')
+                if name:
+                    yield [v.strip() for v in (name, value, desc)]
+        
+    def strFromParamDict(self, param_dict):
+        """
+        Creates string from parameter dictionary
+        {0: ('variable','value'),
+         1: ('variable','value'),
+         ...}
+        """
+        param_str = ""
+        for row, params in param_dict.items():
+            if not params[0]: continue
+            value = 1
+            if params[1]:
+                try:
+                    value = float(params[1])
+                except ValueError:
+                    # convert to default
+                    value = 1
+            param_str += params[0] + " = " + str(value) + "\n"
+        return param_str
+
+
+CUSTOM_TEMPLATE = '''\
+r"""
+Definition
+----------
+
+Calculates %(name)s.
+
+%(description)s
+
+References
+----------
+
+Authorship and Verification
+---------------------------
+
+* **Author:** --- **Date:** %(date)s
+* **Last Modified by:** --- **Date:** %(date)s
+* **Last Reviewed by:** --- **Date:** %(date)s
+"""
+
+from math import *
+from numpy import inf
+
+name = "%(name)s"
+title = "%(title)s"
+description = """%(description)s"""
+
+'''
+
+CUSTOM_TEMPLATE_PD = '''\
+def form_volume(%(args)s):
+    """
+    Volume of the particles used to compute absolute scattering intensity
+    and to weight polydisperse parameter contributions.
+    """
+    return 0.0
+
+def ER(%(args)s):
+    """
+    Effective radius of particles to be used when computing structure factors.
+
+    Input parameters are vectors ranging over the mesh of polydispersity values.
+    """
+    return 0.0
+
+def VR(%(args)s):
+    """
+    Volume ratio of particles to be used when computing structure factors.
+
+    Input parameters are vectors ranging over the mesh of polydispersity values.
+    """
+    return 1.0
+'''
+
+SUM_TEMPLATE = """
+from sasmodels.core import load_model_info
+from sasmodels.sasview_model import make_model_from_info
+
+model_info = load_model_info('{model1}{operator}{model2}')
+model_info.name = '{name}'{desc_line}
+Model = make_model_from_info(model_info)
+"""
+
+if __name__ == '__main__':
+    import sys
+    app = QtWidgets.QApplication(sys.argv)
+    sheet = TabbedModelEditor()
+    sheet.show()
+    sys.exit(app.exec_())
+    
Index: src/sas/qtgui/Utilities/UI/GridPanelUI.ui
===================================================================
--- src/sas/qtgui/Utilities/UI/GridPanelUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UI/GridPanelUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>GridPanelUI</class>
+ <widget class="QMainWindow" name="GridPanelUI">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>939</width>
+    <height>329</height>
+   </rect>
+  </property>
+  <property name="sizePolicy">
+   <sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
+    <horstretch>0</horstretch>
+    <verstretch>0</verstretch>
+   </sizepolicy>
+  </property>
+  <property name="windowTitle">
+   <string>Batch Fitting Results</string>
+  </property>
+  <widget class="QWidget" name="centralwidget">
+   <layout class="QGridLayout" name="gridLayout">
+    <item row="1" column="0">
+     <layout class="QHBoxLayout" name="horizontalLayout">
+      <item>
+       <spacer name="horizontalSpacer">
+        <property name="orientation">
+         <enum>Qt::Horizontal</enum>
+        </property>
+        <property name="sizeHint" stdset="0">
+         <size>
+          <width>40</width>
+          <height>20</height>
+         </size>
+        </property>
+       </spacer>
+      </item>
+      <item>
+       <widget class="QPushButton" name="cmdOK">
+        <property name="text">
+         <string>Close</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QPushButton" name="cmdHelp">
+        <property name="text">
+         <string>Help</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </item>
+    <item row="0" column="0">
+     <widget class="QTableWidget" name="tblParams">
+      <property name="contextMenuPolicy">
+       <enum>Qt::CustomContextMenu</enum>
+      </property>
+      <property name="alternatingRowColors">
+       <bool>true</bool>
+      </property>
+      <property name="selectionBehavior">
+       <enum>QAbstractItemView::SelectRows</enum>
+      </property>
+     </widget>
+    </item>
+   </layout>
+  </widget>
+  <widget class="QMenuBar" name="menubar">
+   <property name="geometry">
+    <rect>
+     <x>0</x>
+     <y>0</y>
+     <width>939</width>
+     <height>26</height>
+    </rect>
+   </property>
+   <widget class="QMenu" name="menuFile">
+    <property name="title">
+     <string>File</string>
+    </property>
+    <addaction name="actionOpen"/>
+    <addaction name="actionOpen_with_Excel"/>
+    <addaction name="separator"/>
+    <addaction name="actionSave"/>
+   </widget>
+   <addaction name="menuFile"/>
+  </widget>
+  <action name="actionOpen">
+   <property name="text">
+    <string>Open</string>
+   </property>
+  </action>
+  <action name="actionOpen_with_Excel">
+   <property name="text">
+    <string>Open with Excel</string>
+   </property>
+  </action>
+  <action name="actionSave">
+   <property name="text">
+    <string>Save</string>
+   </property>
+  </action>
+ </widget>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>cmdOK</sender>
+   <signal>clicked()</signal>
+   <receiver>GridPanelUI</receiver>
+   <slot>close()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>832</x>
+     <y>535</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>495</x>
+     <y>293</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
Index: src/sas/qtgui/Utilities/UI/ModelEditor.ui
===================================================================
--- src/sas/qtgui/Utilities/UI/ModelEditor.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UI/ModelEditor.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ModelEditor</class>
+ <widget class="QWidget" name="ModelEditor">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>549</width>
+    <height>632</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Model Editor</string>
+  </property>
+  <layout class="QGridLayout" name="gridLayout">
+   <item row="0" column="0">
+    <widget class="QGroupBox" name="groupBox_13">
+     <property name="title">
+      <string>Model</string>
+     </property>
+     <layout class="QGridLayout" name="gridLayout_16">
+      <item row="0" column="0">
+       <widget class="QTextEdit" name="txtEditor">
+        <property name="html">
+         <string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
+&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
+p, li { white-space: pre-wrap; }
+&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'MS Shell Dlg 2'; font-size:7.8pt; font-weight:400; font-style:normal;&quot;&gt;
+&lt;p style=&quot;-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;&lt;br /&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
Index: src/sas/qtgui/Utilities/UI/PluginDefinitionUI.ui
===================================================================
--- src/sas/qtgui/Utilities/UI/PluginDefinitionUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UI/PluginDefinitionUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>PluginDefinition</class>
+ <widget class="QWidget" name="PluginDefinition">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>723</width>
+    <height>784</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Plugin Definition</string>
+  </property>
+  <layout class="QGridLayout" name="gridLayout_7">
+   <item row="0" column="0">
+    <widget class="QGroupBox" name="groupBox_6">
+     <property name="title">
+      <string>Plugin name</string>
+     </property>
+     <layout class="QGridLayout" name="gridLayout_5">
+      <item row="0" column="0">
+       <widget class="QLineEdit" name="txtName">
+        <property name="placeholderText">
+         <string>Enter a plugin name</string>
+        </property>
+       </widget>
+      </item>
+      <item row="0" column="1">
+       <widget class="QCheckBox" name="chkOverwrite">
+        <property name="text">
+         <string>Overwrite existing plugin model of this name</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item row="1" column="0">
+    <widget class="QGroupBox" name="groupBox_5">
+     <property name="title">
+      <string>Description</string>
+     </property>
+     <layout class="QGridLayout" name="gridLayout_4">
+      <item row="0" column="0">
+       <widget class="QLineEdit" name="txtDescription">
+        <property name="placeholderText">
+         <string>Enter a description of the model</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item row="2" column="0">
+    <widget class="QGroupBox" name="groupBox">
+     <property name="title">
+      <string>Fit parameters</string>
+     </property>
+     <layout class="QGridLayout" name="gridLayout_3">
+      <item row="0" column="0">
+       <widget class="QGroupBox" name="groupBox_3">
+        <property name="title">
+         <string>Non-polydisperse</string>
+        </property>
+        <layout class="QGridLayout" name="gridLayout">
+         <item row="0" column="0">
+          <widget class="QTableWidget" name="tblParams">
+           <row>
+            <property name="text">
+             <string/>
+            </property>
+           </row>
+           <column>
+            <property name="text">
+             <string>Parameters</string>
+            </property>
+           </column>
+           <column>
+            <property name="text">
+             <string>Initial
+value</string>
+            </property>
+           </column>
+          </widget>
+         </item>
+        </layout>
+       </widget>
+      </item>
+      <item row="0" column="1">
+       <widget class="QGroupBox" name="groupBox_4">
+        <property name="title">
+         <string>Polydisperse</string>
+        </property>
+        <layout class="QGridLayout" name="gridLayout_2">
+         <item row="0" column="0">
+          <widget class="QTableWidget" name="tblParamsPD">
+           <row>
+            <property name="text">
+             <string/>
+            </property>
+           </row>
+           <column>
+            <property name="text">
+             <string>Parameters</string>
+            </property>
+           </column>
+           <column>
+            <property name="text">
+             <string>Initial
+value</string>
+            </property>
+           </column>
+          </widget>
+         </item>
+        </layout>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item row="3" column="0">
+    <widget class="QGroupBox" name="groupBox_2">
+     <property name="title">
+      <string>Function(x)</string>
+     </property>
+     <layout class="QGridLayout" name="gridLayout_6">
+      <item row="0" column="0">
+       <widget class="QTextBrowser" name="txtFunction">
+        <property name="readOnly">
+         <bool>false</bool>
+        </property>
+        <property name="html">
+         <string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
+&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
+p, li { white-space: pre-wrap; }
+&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'MS Shell Dlg 2'; font-size:7.8pt; font-weight:400; font-style:normal;&quot;&gt;
+&lt;p style=&quot;-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;&lt;br /&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
Index: src/sas/qtgui/Utilities/UI/PluginManagerUI.ui
===================================================================
--- src/sas/qtgui/Utilities/UI/PluginManagerUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UI/PluginManagerUI.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,113 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>PluginManagerUI</class>
+ <widget class="QDialog" name="PluginManagerUI">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>528</width>
+    <height>442</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Plugin Manager</string>
+  </property>
+  <layout class="QGridLayout" name="gridLayout">
+   <item row="0" column="0">
+    <widget class="QListWidget" name="lstModels">
+     <property name="contextMenuPolicy">
+      <enum>Qt::ActionsContextMenu</enum>
+     </property>
+     <property name="editTriggers">
+      <set>QAbstractItemView::NoEditTriggers</set>
+     </property>
+     <property name="selectionMode">
+      <enum>QAbstractItemView::ExtendedSelection</enum>
+     </property>
+     <property name="spacing">
+      <number>2</number>
+     </property>
+    </widget>
+   </item>
+   <item row="0" column="1">
+    <layout class="QVBoxLayout" name="verticalLayout">
+     <item>
+      <widget class="QPushButton" name="cmdAdd">
+       <property name="text">
+        <string>Add</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdDuplicate">
+       <property name="text">
+        <string>Duplicate</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdEdit">
+       <property name="text">
+        <string>Edit</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdDelete">
+       <property name="text">
+        <string>Delete</string>
+       </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>
+   </item>
+   <item row="1" column="0" colspan="2">
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <item>
+      <spacer name="horizontalSpacer">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdHelp">
+       <property name="text">
+        <string>Help</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdOK">
+       <property name="text">
+        <string>Close</string>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
Index: src/sas/qtgui/Utilities/UI/TabbedModelEditor.ui
===================================================================
--- src/sas/qtgui/Utilities/UI/TabbedModelEditor.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UI/TabbedModelEditor.ui	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>TabbedModelEditor</class>
+ <widget class="QDialog" name="TabbedModelEditor">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>688</width>
+    <height>697</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Model Editor</string>
+  </property>
+  <property name="windowIcon">
+   <iconset>
+    <normaloff>:/res/ball.ico</normaloff>:/res/ball.ico</iconset>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QTabWidget" name="tabWidget">
+     <property name="currentIndex">
+      <number>-1</number>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <item>
+      <widget class="QPushButton" name="cmdLoad">
+       <property name="text">
+        <string>Load plugin...</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <spacer name="horizontalSpacer">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QDialogButtonBox" name="buttonBox">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="standardButtons">
+        <set>QDialogButtonBox::Apply|QDialogButtonBox::Cancel|QDialogButtonBox::Help</set>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
Index: src/sas/qtgui/Utilities/UnitTesting/GridPanelTest.py
===================================================================
--- src/sas/qtgui/Utilities/UnitTesting/GridPanelTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UnitTesting/GridPanelTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,150 @@
+import sys
+import numpy as np
+import unittest
+from unittest.mock import mock_open, patch
+import webbrowser
+import logging
+
+from unittest.mock import MagicMock
+
+from PyQt5 import QtGui, QtWidgets
+
+# set up import paths
+import path_prepare
+
+from sas.qtgui.UnitTesting.TestUtils import QtSignalSpy
+
+from sas.sascalc.fit.AbstractFitEngine import FResult
+from sasmodels.sasview_model import load_standard_models
+from sas.qtgui.Plotting.PlotterData import Data1D
+
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+# Local
+from sas.qtgui.Utilities.GridPanel import BatchOutputPanel
+
+if not QtWidgets.QApplication.instance():
+    app = QtWidgets.QApplication(sys.argv)
+
+class BatchOutputPanelTest(unittest.TestCase):
+    '''Test the batch output dialog'''
+    def setUp(self):
+        '''Create the dialog'''
+        # dummy perspective
+        class dummy_manager(object):
+            def communicator(self):
+                return GuiUtils.Communicate()
+            def communicate(self):
+                return GuiUtils.Communicate()
+        self.widget = BatchOutputPanel(parent=dummy_manager(), output_data=self.output_for_test())
+        test_table = {"p1":[1,2,3],
+                      "p2":[4,5,None],
+                      "":["a",credits,],
+                      "":[]}
+
+    def tearDown(self):
+        '''Destroy the GUI'''
+        self.widget.close()
+        self.widget = None
+
+    def output_for_test(self):
+        ''' define DATA1D structure for testing'''
+        # dummy parameter set
+        m = None
+        for m in load_standard_models():
+            if m.name == "core_shell_ellipsoid":
+                model = m()
+        self.assertIsNotNone(m)
+        data = Data1D(x=[1,2], y=[3,4], dx=[0.1, 0.1], dy=[0.,0.])
+        param_list = ['sld_shell', 'sld_solvent']
+        output = FResult(model=model, data=data, param_list=param_list)
+        output.theory = np.array([0.1,0.2])
+        output.pvec = np.array([0.1, 0.02])
+        output.residuals = np.array([0.01, 0.02])
+        output.fitness = 9000.0
+        output.fitter_id = 200
+        output_data = [[output],[output]]
+        return output_data
+
+    def testDefaults(self):
+        '''Test the GUI in its default state'''
+        self.assertIsInstance(self.widget, QtWidgets.QMainWindow)
+        # Default title
+        self.assertEqual(self.widget.windowTitle(), "Grid Panel")
+
+        # non-modal window
+        self.assertFalse(self.widget.isModal())
+
+    def testActionLoadData(self):
+        '''Test CSV data loader'''
+        test_csv = mock_open(read_data = ("""\
+File generated by SasView\n
+Chi2,Data,scale,background,radius_equat_core,x_core,thick_shell,x_polar_shell,sld_core,sld_shell,sld_solvent,theta,phi,\n
+1917.8,cyl_400_40.txt,1,0.001,20,3,30,1,-85.088,-97.636,-92.797,0,0,\n
+2.6169,cyl_400_20.txt,1,0.001,20,3,30,1,914.64,906.09,905.67,0,0,\n
+"""))
+        logging.info = MagicMock()
+        self.widget.setupTableFromCSV = MagicMock()
+
+        # No filename given
+        QtWidgets.QFileDialog.getOpenFileName = MagicMock(return_value=[""])
+        self.widget.actionLoadData()
+        # Assure parser wasn't called and logging got a message
+        self.assertTrue(logging.info.called_once())
+        self.assertIn("No data", logging.info.call_args[0][0])
+
+        # Filename given
+        QtWidgets.QFileDialog.getOpenFileName = MagicMock(return_value="test")
+        with patch('builtins.open', test_csv):
+            self.widget.actionLoadData()
+            # Assure parser was called
+            self.assertTrue(self.widget.actionLoadData)
+            self.widget.setupTableFromCSV.assert_called_once()
+            self.assertIn("File generated by SasView", self.widget.setupTableFromCSV.call_args[0][0][0])
+
+    def notestPlotFits(self):
+        '''Test plot generation from selected table rows'''
+        # mock tested calls
+        #GuiUtils.Communicate.plot
+        spy_plot_signal = QtSignalSpy(self.widget.communicate, self.widget.communicate().plotFromFilenameSignal)
+        # Select row #1
+        self.widget.tblParams.selectRow(0)
+        QtWidgets.QApplication.processEvents()
+
+        # See that the signal was emitted
+        self.assertEqual(spy_plot_signal.count(), 1)
+        self.assertIn("ddd", str(spy_plot_signal.called()[0]['args'][0]))
+
+
+    def testDataFromTable(self):
+        '''Test dictionary generation from data'''
+        params = self.widget.dataFromTable(self.widget.tblParams)
+        self.assertEqual(len(params), 13)
+        self.assertEqual(params['Chi2'][0], '9000')
+        self.assertEqual(params['Data'][1], '')
+        self.assertEqual(params['sld_solvent'][1], '0.02')
+
+    def testActionSendToExcel(self):
+        '''Test Excel bindings'''
+        pass
+
+    def testActionSaveFile(self):
+        '''Test file save'''
+        self.widget.writeBatchToFile = MagicMock()
+
+        # user cancels dialog
+        QtWidgets.QFileDialog.getSaveFileName = MagicMock(return_value=("","BOOP"))
+        # write not called
+        self.widget.actionSaveFile()
+        self.widget.writeBatchToFile.assert_not_called()
+
+        # user chooses proper name
+        QtWidgets.QFileDialog.getSaveFileName = MagicMock(return_value=("plop","BOOP"))
+        # write called
+        self.widget.actionSaveFile()
+        self.widget.writeBatchToFile.assert_called_once()
+
+    def testSetupTableFromCSV(self):
+        '''Test generation of grid table rows from a CSV file'''
+        pass
+
+
Index: src/sas/qtgui/Utilities/UnitTesting/ModelEditorTest.py
===================================================================
--- src/sas/qtgui/Utilities/UnitTesting/ModelEditorTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UnitTesting/ModelEditorTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,51 @@
+import sys
+import unittest
+import logging
+
+from PyQt5.QtGui import *
+from PyQt5.QtCore import *
+from PyQt5.QtWidgets import *
+
+# set up import paths
+import sas.qtgui.path_prepare
+
+from UnitTesting.TestUtils import QtSignalSpy
+
+# Local
+from sas.qtgui.Utilities.ModelEditor import ModelEditor
+from sas.qtgui.Utilities.PythonSyntax import PythonHighlighter
+
+if not QApplication.instance():
+    app = QApplication(sys.argv)
+
+class ModelEditorTest(unittest.TestCase):
+    def setUp(self):
+        """
+        Prepare the editor
+        """
+        self.widget = ModelEditor(None)
+
+    def tearDown(self):
+        """Destroy the DataOperationUtility"""
+        self.widget.close()
+        self.widget = None
+
+    def testDefaults(self):
+        """Test the GUI in its default state"""
+        self.assertIsInstance(self.widget.highlight, PythonHighlighter)
+
+    def testEdit(self):
+        """Test that a signal is emitted on edit"""
+        spy_signal = QtSignalSpy(self.widget, self.widget.modelModified)
+
+        # Edit text
+        text = "test"
+        self.widget.txtEditor.setPlainText(text)
+
+        # Assure the signal got emitted
+        self.assertEqual(spy_signal.count(), 1)
+
+        # Assure getModel() returns right content
+        self.assertTrue(text, self.widget.txtEditor.toPlainText())
+
+
Index: src/sas/qtgui/Utilities/UnitTesting/PluginDefinitionTest.py
===================================================================
--- src/sas/qtgui/Utilities/UnitTesting/PluginDefinitionTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UnitTesting/PluginDefinitionTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,175 @@
+import sys
+import unittest
+import logging
+
+from PyQt5.QtGui import *
+from PyQt5.QtCore import *
+from PyQt5.QtWidgets import *
+
+# set up import paths
+import sas.qtgui.path_prepare
+
+from UnitTesting.TestUtils import QtSignalSpy
+
+# Local
+from sas.qtgui.Utilities.PluginDefinition import PluginDefinition
+from sas.qtgui.Utilities.PythonSyntax import PythonHighlighter
+
+#if not QApplication.instance():
+#    app = QApplication(sys.argv)
+app = QApplication(sys.argv)
+
+class PluginDefinitionTest(unittest.TestCase):
+    def setUp(self):
+        """
+        Prepare the editor
+        """
+        self.widget = PluginDefinition(None)
+
+    def tearDown(self):
+        """Destroy the DataOperationUtility"""
+        self.widget.close()
+        self.widget = None
+
+    def testDefaults(self):
+        """Test the GUI in its default state"""
+        self.assertIsInstance(self.widget.highlight, PythonHighlighter)
+        self.assertIsInstance(self.widget.parameter_dict, dict)
+        self.assertIsInstance(self.widget.pd_parameter_dict, dict)
+
+        self.assertEqual(len(self.widget.model), 6)
+        self.assertIn("filename", self.widget.model.keys())
+        self.assertIn("overwrite", self.widget.model.keys())
+        self.assertIn("description", self.widget.model.keys())
+        self.assertIn("parameters", self.widget.model.keys())
+        self.assertIn("pd_parameters", self.widget.model.keys())
+        self.assertIn("text", self.widget.model.keys())
+
+    def testOnOverwrite(self):
+        """See what happens when the overwrite checkbox is selected"""
+        spy_signal = QtSignalSpy(self.widget, self.widget.modelModified)
+
+        # check the default
+        self.assertFalse(self.widget.chkOverwrite.isChecked())
+
+        # Change the state
+        self.widget.chkOverwrite.setChecked(True)
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 1)
+
+        # model dict updated
+        self.assertTrue(self.widget.model['overwrite'])
+
+    def testOnPluginNameChanged(self):
+        """See what happens when the name of the plugin changes"""
+        spy_signal = QtSignalSpy(self.widget, self.widget.modelModified)
+
+        # Change the name
+        new_text = "Duck season"
+        self.widget.txtName.setText(new_text)
+        self.widget.txtName.editingFinished.emit()
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 1)
+
+        # model dict updated
+        self.assertTrue(self.widget.model['filename'] == new_text)
+
+    def testOnDescriptionChanged(self):
+        """See what happens when the description of the plugin changes"""
+        spy_signal = QtSignalSpy(self.widget, self.widget.modelModified)
+
+        # Change the name
+        new_text = "Rabbit season"
+        self.widget.txtDescription.setText(new_text)
+        self.widget.txtDescription.editingFinished.emit()
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 1)
+
+        # model dict updated
+        self.assertTrue(self.widget.model['description'] == new_text)
+
+    def testOnParamsChanged(self):
+        """See what happens when parameters change"""
+        spy_signal = QtSignalSpy(self.widget, self.widget.modelModified)
+
+        # number of rows. default=1
+        self.assertEqual(self.widget.tblParams.rowCount(), 1)
+
+        # number of columns. default=2
+        self.assertEqual(self.widget.tblParams.columnCount(), 2)
+
+        # Change the param
+        new_param = "shoot"
+        self.widget.tblParams.setItem(0,0,QTableWidgetItem(new_param))
+        #self.widget.tblParams.editingFinished.emit()
+
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 1)
+
+        # model dict updated
+        self.assertEqual(self.widget.model['parameters'], {0: (new_param, None)})
+
+        # Change the value
+        new_value = "BOOM"
+        self.widget.tblParams.setItem(0,1,QTableWidgetItem(new_value))
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 2)
+
+        # model dict updated
+        self.assertEqual(self.widget.model['parameters'], {0: (new_param, new_value)})
+
+        # See that the number of rows increased
+        self.assertEqual(self.widget.tblParams.rowCount(), 2)
+
+    def testOnPDParamsChanged(self):
+        """See what happens when pd parameters change"""
+        spy_signal = QtSignalSpy(self.widget, self.widget.modelModified)
+
+        # number of rows. default=1
+        self.assertEqual(self.widget.tblParamsPD.rowCount(), 1)
+
+        # number of columns. default=2
+        self.assertEqual(self.widget.tblParamsPD.columnCount(), 2)
+
+        # Change the param
+        new_param = "shoot"
+        self.widget.tblParamsPD.setItem(0,0,QTableWidgetItem(new_param))
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 1)
+
+        # model dict updated
+        self.assertEqual(self.widget.model['pd_parameters'], {0: (new_param, None)})
+
+        # Change the value
+        new_value = "BOOM"
+        self.widget.tblParamsPD.setItem(0,1,QTableWidgetItem(new_value))
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 2)
+
+        # model dict updated
+        self.assertTrue(self.widget.model['pd_parameters'] == {0: (new_param, new_value)})
+
+        # See that the number of rows increased
+        self.assertEqual(self.widget.tblParamsPD.rowCount(), 2)
+
+    def testOnFunctionChanged(self):
+        """See what happens when the model text changes"""
+        spy_signal = QtSignalSpy(self.widget, self.widget.modelModified)
+
+        # Change the text
+        new_model = "1\n2\n"
+        self.widget.txtFunction.setPlainText(new_model)
+
+        # Check the signal
+        self.assertEqual(spy_signal.count(), 1)
+
+        # model dict updated
+        self.assertEqual(self.widget.model['text'], new_model.rstrip())
+
Index: src/sas/qtgui/Utilities/UnitTesting/TabbedModelEditorTest.py
===================================================================
--- src/sas/qtgui/Utilities/UnitTesting/TabbedModelEditorTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Utilities/UnitTesting/TabbedModelEditorTest.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,299 @@
+import os
+import sys
+import unittest
+import logging
+
+from unittest.mock import MagicMock
+
+from PyQt5.QtGui import *
+from PyQt5.QtCore import *
+from PyQt5.QtWidgets import *
+
+# set up import paths
+import sas.qtgui.path_prepare
+
+from UnitTesting.TestUtils import QtSignalSpy
+
+# Local
+from sas.qtgui.Utilities.TabbedModelEditor import TabbedModelEditor
+from sas.qtgui.Utilities.PluginDefinition import PluginDefinition
+from sas.qtgui.Utilities.ModelEditor import ModelEditor
+
+
+#if not QApplication.instance():
+#    app = QApplication(sys.argv)
+app = QApplication(sys.argv)
+
+class TabbedModelEditorTest(unittest.TestCase):
+    def setUp(self):
+        """
+        Prepare the editors
+        """
+        self.widget = TabbedModelEditor(None)
+        self.widget_edit = TabbedModelEditor(None, edit_only=True)
+
+    def tearDown(self):
+        """Destroy the DataOperationUtility"""
+        QMessageBox.exec = MagicMock(return_value=QMessageBox.Discard)
+        self.widget.close()
+        self.widget = None
+        self.widget_edit.close()
+        self.widget_edit = None
+
+    def testDefaults(self):
+        """Test the GUI in its default state"""
+        self.assertEqual(self.widget.filename, "")
+        self.assertEqual(self.widget.window_title, "Model Editor")
+        self.assertFalse(self.widget.is_modified)
+        self.assertFalse(self.widget.edit_only)
+        self.assertTrue(self.widget_edit.edit_only)
+
+        # Add model
+        #self.assertFalse(self.widget.cmdLoad.isVisible())
+        #self.assertTrue(self.widget_edit.cmdLoad.isVisible())
+        self.assertIsInstance(self.widget.plugin_widget, PluginDefinition)
+        self.assertIsInstance(self.widget.editor_widget, ModelEditor)
+        # tabs
+        self.assertEqual(self.widget.tabWidget.count(), 2)
+        self.assertFalse(self.widget.editor_widget.isEnabled())
+        self.assertEqual(self.widget_edit.tabWidget.count(), 1)
+        self.assertFalse(self.widget_edit.editor_widget.isEnabled())
+
+        #buttons
+        self.assertFalse(self.widget.buttonBox.button(QDialogButtonBox.Apply).isEnabled())
+        self.assertEqual(self.widget.buttonBox.button(QDialogButtonBox.Apply).text(), "Apply")
+        self.assertEqual(self.widget_edit.buttonBox.button(QDialogButtonBox.Apply).text(), "Save")
+
+    def testSetPluginActive(self):
+        """ Enables the plugin editor """
+        # by default it is on
+        self.assertTrue(self.widget.plugin_widget.isEnabled())
+        # Let's disable it
+        self.widget.setPluginActive(False)
+        self.assertFalse(self.widget.plugin_widget.isEnabled())
+        # and back to enabled
+        self.widget.setPluginActive(True)
+        self.assertTrue(self.widget.plugin_widget.isEnabled())
+
+    def notestCloseEvent(self):
+        """Test the close event wrt. saving info"""
+        event = QObject()
+        event.accept = MagicMock()
+
+        # 1. no changes to document - straightforward exit
+        self.widget.is_modified = False
+        self.widget.closeEvent(event)
+        self.assertTrue(event.accept.called_once())
+
+        # 2. document changed, cancelled
+        self.widget.is_modified = True
+        QMessageBox.exec = MagicMock(return_value=QMessageBox.Cancel)
+        self.widget.closeEvent(event)
+        self.assertTrue(QMessageBox.exec.called_once())
+        # no additional calls to event accept
+        self.assertTrue(event.accept.called_once())
+
+        # 3. document changed, save
+        QMessageBox.exec = MagicMock(return_value=QMessageBox.Save)
+        self.widget.filename = "random string #8"
+        self.widget.updateFromEditor = MagicMock()
+        self.widget.closeEvent(event)
+        self.assertTrue(QMessageBox.exec.called_once())
+        # no additional calls to event accept
+        self.assertTrue(event.accept.called_once())
+        self.assertTrue(self.widget.updateFromEditor.called_once())
+
+    def testOnApply(self):
+        """Test the Apply/Save event"""
+        # name the plugin
+        self.widget.plugin_widget.txtName.setText("Uncharacteristically eloquent filename")
+        self.widget.plugin_widget.txtName.editingFinished.emit()
+
+        # make sure the flag is set correctly
+        self.assertTrue(self.widget.is_modified)
+
+        # default tab
+        self.widget.updateFromPlugin = MagicMock()
+        self.widget.onApply()
+        self.assertTrue(self.widget.updateFromPlugin.called_once())
+
+        # switch tabs
+        self.widget.tabWidget.setCurrentIndex(1)
+        self.widget.updateFromEditor = MagicMock()
+        self.widget.onApply()
+        self.assertTrue(self.widget.updateFromEditor.called_once())
+
+    def testEditorModelModified(self):
+         """Test reaction to direct edit in the editor """
+         # Switch to the Editor tab
+         self.widget.tabWidget.setCurrentIndex(1)
+         self.assertFalse(self.widget.is_modified)
+
+         # add some text. This invokes tested method
+         self.widget.editor_widget.txtEditor.setPlainText("Plain Text")
+
+         # assure relevant functionality is invoked
+         self.assertTrue(self.widget.buttonBox.button(QDialogButtonBox.Apply).isEnabled())
+         self.assertTrue(self.widget.is_modified)
+         self.assertIn("*", self.widget.windowTitle())
+
+
+    def testPluginModelModified(self):
+        """Test reaction to direct edit in plugin wizard"""
+        self.assertFalse(self.widget.is_modified)
+
+        # Call the tested method with no filename defined
+        self.widget.pluginModelModified()
+
+        # Assure the apply button is disabled
+        self.assertFalse(self.widget.buttonBox.button(QDialogButtonBox.Apply).isEnabled())
+
+        # Modify plugin name
+        new_name = "NAME"
+        self.widget.plugin_widget.txtName.setText(new_name)
+        self.widget.plugin_widget.txtName.editingFinished.emit()
+
+        # Assure relevant functionality is invoked
+        self.assertIn("*", self.widget.windowTitle())
+        self.assertIn(new_name, self.widget.windowTitle())
+        self.assertTrue(self.widget.editor_widget.isEnabled())
+        self.assertTrue(self.widget.buttonBox.button(QDialogButtonBox.Apply).isEnabled())
+        self.assertTrue(self.widget.is_modified)
+
+    def testSetTabEdited(self):
+        """
+        Test the simple string update method
+        """
+        title = "title"
+        title_star = "title*"
+        self.widget.setWindowTitle(title)
+
+        # 1. -> edited
+        self.widget.setTabEdited(True)
+        self.assertIn("*", self.widget.windowTitle())
+        # make sure we don't get another star
+        self.widget.setWindowTitle(title_star)
+        self.widget.setTabEdited(True)
+        self.assertEqual(title_star, self.widget.windowTitle())
+
+        # 2. -> not edited
+        self.widget.setWindowTitle(title_star)
+        self.widget.setTabEdited(False)
+        self.assertEqual(title, self.widget.windowTitle())
+        # No changes when no star in title
+        self.widget.setWindowTitle(title)
+        self.widget.setTabEdited(False)
+        self.assertEqual(title, self.widget.windowTitle())
+
+    def testUpdateFromEditor(self):
+        """
+        Test the behaviour on editor window being updated
+        """
+        # Assure the test rises when no filename present
+        self.widget.filename = ""
+        with self.assertRaises(Exception):
+            self.widget.updateFromEditor()
+
+        # change the filename
+        self.widget.filename="testfile.py"
+        self.widget.writeFile = MagicMock()
+        boring_text = "so bored with unit tests"
+        self.widget.editor_widget.txtEditor.toPlainText = MagicMock(return_value=boring_text)
+        self.widget.writeFile = MagicMock()
+        #invoke the method
+        self.widget.updateFromEditor()
+
+        # Test the behaviour
+        self.assertTrue(self.widget.writeFile.called_once())
+        self.assertTrue(self.widget.writeFile.called_with('testfile.py', boring_text))
+
+    def testCanWriteModel(self):
+        """
+        Test if the model can be written to a file, given initial conditions
+        """
+        test_model = {'overwrite':False,
+                      'text':"return"}
+        test_path = "test.py"
+
+        with self.assertRaises(Exception):
+            self.widget.canWriteModel()
+
+        with self.assertRaises(Exception):
+            self.widget.canWriteModel(model=test_model)
+
+        with self.assertRaises(Exception):
+            self.widget.canWriteModel(full_path=test_path)
+
+        # 1. Overwrite box unchecked, file exists
+        os.path.isfile = MagicMock(return_value=True)
+        QMessageBox.critical = MagicMock()
+
+        ret = self.widget.canWriteModel(model=test_model, full_path=test_path)
+        self.assertFalse(ret)
+        self.assertTrue(QMessageBox.critical.called_once())
+        self.assertIn('Plugin Error', QMessageBox.critical.call_args[0][1])
+        self.assertIn('Plugin with specified name already exists', QMessageBox.critical.call_args[0][2])
+
+        # 2. Overwrite box checked, file exists, empty model
+        os.path.isfile = MagicMock(return_value=True)
+        test_model['overwrite']=True
+        test_model['text'] = ""
+        QMessageBox.critical = MagicMock()
+
+        ret = self.widget.canWriteModel(model=test_model, full_path=test_path)
+        self.assertFalse(ret)
+        self.assertTrue(QMessageBox.critical.called_once())
+        self.assertIn('Plugin Error', QMessageBox.critical.call_args[0][1])
+        self.assertIn('Error: Function is not defined', QMessageBox.critical.call_args[0][2])
+
+        # 3. Overwrite box unchecked, file doesn't exists, model with no 'return'
+        os.path.isfile = MagicMock(return_value=False)
+        test_model['overwrite']=False
+        test_model['text'] = "I am a simple model"
+        QMessageBox.critical = MagicMock()
+
+        ret = self.widget.canWriteModel(model=test_model, full_path=test_path)
+        self.assertFalse(ret)
+        self.assertTrue(QMessageBox.critical.called_once())
+        self.assertIn('Plugin Error', QMessageBox.critical.call_args[0][1])
+        self.assertIn('Error: The func(x) must', QMessageBox.critical.call_args[0][2])
+
+        # 4. Overwrite box unchecked, file doesnt exist, good model
+        os.path.isfile = MagicMock(return_value=False)
+        test_model['text'] = "return"
+        ret = self.widget.canWriteModel(model=test_model, full_path=test_path)
+        self.assertTrue(ret)
+
+    def testGenerateModel(self):
+        """
+        Test the model text creation from the dictionary
+        """
+        pass
+
+    def notestCheckModel(self):
+        """test the sasmodel model checker """
+        pass
+
+    def testGetParamHelper(self):
+        """
+        Test the convenience method for converting 
+        GUI parameter representation into sasmodel comprehensible string
+        """
+        pass
+
+    def testStrFromParamDict(self):
+        """
+        Test Conversion from dict to param string
+        """
+        test_dict = {0: ('a', '1'),
+                     1: ('eee',''),
+                     2: ('bob', None),
+                     3: ('',-1),
+                     4: (None, None)}
+
+        #{0: ('variable','value'),
+        # 1: ('variable','value'),
+
+        test_str = self.widget.strFromParamDict(test_dict)
+        self.assertIn("", test_str)
+
Index: src/sas/qtgui/images/icons/layers.svg
===================================================================
--- src/sas/qtgui/images/icons/layers.svg	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/images/icons/layers.svg	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
+<g>
+	<path d="M9.1,36.6l36,20.3c1.5,1,3.2,1.5,4.9,1.5s3.4-0.5,4.9-1.5l36-20.3c1.9-1.1,3.1-3,3.1-5.2c0-2.2-1.2-4.1-3.1-5.2l-36-19.9
+		c-2.9-2-6.8-2-9.8,0l-36,19.9c-2,1.1-3.1,3-3.1,5.2C6,33.6,7.1,35.5,9.1,36.6z M11.1,29.8l36-19.9c0.1,0,0.1-0.1,0.2-0.1
+		c0,0,0,0,0,0c0,0,0,0,0,0c1.6-1.1,3.8-1.1,5.4,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0.1,0,0.1,0.1,0.2,0.1l36,19.9
+		c0.7,0.4,1.1,1,1.1,1.7c0,0.7-0.4,1.3-1.1,1.7L52.9,53.5c-0.1,0-0.1,0.1-0.2,0.1c-1.6,1.1-3.8,1.1-5.4,0c-0.1,0-0.1-0.1-0.2-0.1
+		L11.1,33.1c-0.7-0.4-1.1-1-1.1-1.7C10,30.8,10.4,30.1,11.1,29.8z"/>
+	<path d="M90.9,44.8c-1-0.5-2.2-0.2-2.7,0.8c-0.5,1-0.2,2.2,0.8,2.7c0.7,0.4,1.1,1,1.1,1.7c0,0.7-0.4,1.3-1.1,1.7L52.9,72.1
+		c-0.1,0-0.1,0.1-0.2,0.1c-1.6,1.1-3.8,1.1-5.4,0c-0.1,0-0.1-0.1-0.2-0.1L11.1,51.7c-0.7-0.4-1.1-1-1.1-1.7c0-0.7,0.4-1.3,1.1-1.7
+		c1-0.5,1.3-1.8,0.8-2.7c-0.5-1-1.8-1.3-2.7-0.8c-2,1.1-3.1,3-3.1,5.2c0,2.2,1.1,4.1,3.1,5.2l36,20.3c1.5,1,3.2,1.5,4.9,1.5
+		c1.7,0,3.4-0.5,4.9-1.5l36-20.3c1.9-1.1,3.1-3,3.1-5.2C94,47.9,92.8,45.9,90.9,44.8z"/>
+	<path d="M90.9,62.8c-1-0.5-2.2-0.2-2.7,0.8c-0.5,1-0.2,2.2,0.8,2.7c0.7,0.4,1.1,1,1.1,1.7c0,0.7-0.4,1.3-1.1,1.7L52.9,90.1
+		c-0.1,0-0.1,0.1-0.2,0.1c-1.6,1.1-3.8,1.1-5.4,0c-0.1,0-0.1-0.1-0.2-0.1L11.1,69.7c-0.7-0.4-1.1-1-1.1-1.7c0-0.7,0.4-1.3,1.1-1.7
+		c1-0.5,1.3-1.8,0.8-2.7s-1.8-1.3-2.7-0.8c-2,1.1-3.1,3-3.1,5.2c0,2.2,1.1,4.1,3.1,5.2l36,20.3c1.5,1,3.2,1.5,4.9,1.5
+		c1.7,0,3.4-0.5,4.9-1.5l36-20.3c1.9-1.1,3.1-3,3.1-5.2C94,65.9,92.8,63.9,90.9,62.8z"/>
+</g>
+</svg>
Index: src/sas/qtgui/images/icons/link.svg
===================================================================
--- src/sas/qtgui/images/icons/link.svg	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/images/icons/link.svg	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
+<path d="M50.5,39.5c-1.7,0-3-1.3-3-3c0-4.3,1.7-8.3,4.7-11.3L66.3,11c3-3,7-4.7,11.3-4.7C81.9,6.4,86,8,89,11c3,3,4.7,7,4.7,11.3
+	c0,4.3-1.7,8.3-4.7,11.3L74.8,47.8c-3,3-7,4.7-11.3,4.7c-1.7,0-3-1.3-3-3c0-1.7,1.3-3,3-3c2.7,0,5.2-1,7.1-2.9l14.1-14.1
+	c1.9-1.9,2.9-4.4,2.9-7.1s-1-5.2-2.9-7.1c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2,1-7.1,2.9L56.4,29.4c-1.9,1.9-2.9,4.4-2.9,7.1
+	C53.5,38.2,52.2,39.5,50.5,39.5z M66.3,33.7c-1.2-1.2-3.1-1.2-4.2,0L33.7,62c-0.6,0.6-0.9,1.4-0.9,2.1s0.3,1.5,0.9,2.1
+	c1.2,1.2,3.1,1.2,4.2,0L66.3,38C67.4,36.8,67.4,34.9,66.3,33.7z M33.7,89l14.1-14.1c3-3,4.7-7,4.7-11.3c0-1.7-1.3-3-3-3
+	c-1.7,0-3,1.3-3,3c0,2.7-1,5.2-2.9,7.1L29.4,84.7c-3.9,3.9-10.2,3.9-14.1,0c-3.9-3.9-3.9-10.2,0-14.1l14.1-14.1
+	c1.9-1.9,4.4-2.9,7.1-2.9c1.7,0,3-1.3,3-3c0-1.7-1.3-3-3-3c-4.3,0-8.3,1.7-11.3,4.7L11.1,66.3c-3.1,3.1-4.7,7.2-4.7,11.3
+	c0,4.1,1.6,8.2,4.7,11.3C17.3,95.2,27.4,95.2,33.7,89z"/>
+</svg>
