Ejemplo n.º 1
0
    def __init__(self, values=[], parent=None):
        super(EnumComboBox, self).__init__(parent)

        self.setFocusPolicy(QtCore.Qt.StrongFocus)
        self.setEditable(True)
        self.completer = QCompleter(self)

        # always show all completions
        self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion)
        self.pFilterModel = QtCore.QSortFilterProxyModel(self)
        self.pFilterModel.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)

        self.setInsertPolicy(self.NoInsert)

        self.completer.setPopup(self.view())

        self.setCompleter(self.completer)

        self.lineEdit().textEdited[str].connect(self.onTextEdited)
        self.lineEdit().returnPressed.connect(self.onReturnPressed)
        self.completer.activated.connect(self.setTextIfCompleterIsClicked)

        self.model = QtGui.QStandardItemModel()
        for i, value in enumerate(values):
            item = QtGui.QStandardItem(value)
            self.model.setItem(i, 0, item)
        self.setModel(self.model)
        self.setModelColumn(0)
        self.currentIndexChanged.connect(self.onIndexChanged)
        self.activated.connect(self.onIndexChanged)
Ejemplo n.º 2
0
 def __init__(self, parent=None):
     super(WCompletionTextEdit, self).__init__(parent)
     self.setMinimumWidth(400)
     self.completer = QCompleter(_defaultWordList, self)
     self.moveCursor(QtGui.QTextCursor.End)
     font = QtGui.QFont()
     font.setFamily("Consolas")
     self.setFont(font)
     self.setCompleter(self.completer)
Ejemplo n.º 3
0
class EnumComboBox(QComboBox):
    changeCallback = QtCore.Signal(str)

    def __init__(self, values=[], parent=None):
        super(EnumComboBox, self).__init__(parent)

        self.setFocusPolicy(QtCore.Qt.StrongFocus)
        self.setEditable(True)
        self.completer = QCompleter(self)

        # always show all completions
        self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion)
        self.pFilterModel = QtCore.QSortFilterProxyModel(self)
        self.pFilterModel.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)

        self.setInsertPolicy(self.NoInsert)

        self.completer.setPopup(self.view())

        self.setCompleter(self.completer)

        self.lineEdit().textEdited[str].connect(
            self.pFilterModel.setFilterFixedString)
        self.lineEdit().returnPressed.connect(self.onReturnPressed)
        self.completer.activated.connect(self.setTextIfCompleterIsClicked)

        self.model = QtGui.QStandardItemModel()
        for i, value in enumerate(values):
            item = QtGui.QStandardItem(value)
            self.model.setItem(i, 0, item)
        self.setModel(self.model)
        self.setModelColumn(0)
        self.currentIndexChanged.connect(self.onIndexChanged)

    def onReturnPressed(self):
        self.changeCallback.emit(self.currentText())

    def onIndexChanged(self, index):
        self.changeCallback.emit(self.currentText())

    def setModel(self, model):
        super(EnumComboBox, self).setModel(model)
        self.pFilterModel.setSourceModel(model)
        self.completer.setModel(self.pFilterModel)

    def setModelColumn(self, column):
        self.completer.setCompletionColumn(column)
        self.pFilterModel.setFilterKeyColumn(column)
        super(EnumComboBox, self).setModelColumn(column)

    def view(self):
        return self.completer.popup()

    def index(self):
        return self.currentIndex()

    def setTextIfCompleterIsClicked(self, text):
        if text:
            index = self.findText(text)
            self.setCurrentIndex(index)
Ejemplo n.º 4
0
    def __init__(self, commands=[], parent=None):
        super(ConsoleInput, self).__init__(parent=parent)

        self._commands = commands

        self._model = QStringListModel()
        self._model.setStringList(self._commands)
        self._completer = QCompleter(self)
        self._completer.setModel(self._model)
        self._completer.setCompletionMode(QCompleter.PopupCompletion)
        self._completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.setCompleter(self._completer)
        self.setFont(QFont('Arial', 9, QFont.Bold, False))
Ejemplo n.º 5
0
class WCompletionTextEdit(QPlainTextEdit):
    def __init__(self, parent=None):
        super(WCompletionTextEdit, self).__init__(parent)
        self.setMinimumWidth(400)
        self.completer = QCompleter(_defaultWordList, self)
        self.moveCursor(QtGui.QTextCursor.End)
        font = QtGui.QFont()
        font.setFamily("Consolas")
        self.setFont(font)
        self.setCompleter(self.completer)

    def setCompleter(self, completer):
        completer.setWidget(self)
        completer.setCompletionMode(QCompleter.PopupCompletion)
        completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
        self.completer = completer
        self.completer.activated.connect(self.insertCompletion)

    def insertCompletion(self, completion):
        tc = self.textCursor()
        tc.movePosition(QtGui.QTextCursor.Left)
        tc.movePosition(QtGui.QTextCursor.EndOfWord)
        tc.insertText(completion.replace(self.completer.completionPrefix(), ''))
        self.setTextCursor(tc)

    def textUnderCursor(self):
        tc = self.textCursor()
        tc.select(QtGui.QTextCursor.WordUnderCursor)
        return tc.selectedText()

    def focusInEvent(self, event):
        if self.completer:
            self.completer.setWidget(self)
        QPlainTextEdit.focusInEvent(self, event)

    def keyPressEvent(self, event):
        if self.completer and self.completer.popup().isVisible():
            if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return, QtCore.Qt.Key_Escape, QtCore.Qt.Key_Tab, QtCore.Qt.Key_Backtab):
                event.ignore()
                return

        # has ctrl-space been pressed??
        isShortcut = (event.modifiers() == QtCore.Qt.ControlModifier and event.key() == QtCore.Qt.Key_Space)
        if (not self.completer or not isShortcut):
            QPlainTextEdit.keyPressEvent(self, event)

        # ctrl or shift key on it's own??
        ctrlOrShift = event.modifiers() in (QtCore.Qt.ControlModifier, QtCore.Qt.ShiftModifier)
        if ctrlOrShift and event.text() == '':
            # ctrl or shift key on it's own
            return

        # end of word
        eow = "~!@#$%^&*()_+{}|:\"<>?,./;'[]\\-="

        hasModifier = ((event.modifiers() != QtCore.Qt.NoModifier) and not ctrlOrShift)

        completionPrefix = self.textUnderCursor()

        if (not isShortcut and (hasModifier or event.text() == '' or len(completionPrefix) < 3 or event.text()[:-1] in eow)):
            self.completer.popup().hide()
            return

        if (completionPrefix != self.completer.completionPrefix()):
            self.completer.setCompletionPrefix(completionPrefix)
            popup = self.completer.popup()
            popup.setCurrentIndex(
                self.completer.completionModel().index(0, 0))

        cr = self.cursorRect()
        cr.setWidth(self.completer.popup().sizeHintForColumn(0) + self.completer.popup().verticalScrollBar().sizeHint().width())
        # popup it up!
        self.completer.complete(cr)
Ejemplo n.º 6
0
    def __init__(self):
        super().__init__(None)
        self.setupUi(self)

        try:
            self.menu_File.setToolTipsVisible(True)
            self.menu_Mesh.setToolTipsVisible(True)
            self.menu_Help.setToolTipsVisible(True)
        except AttributeError:
            pass

        self.plot_widget = MatplotlibWidget(self.plottingArea)

        def set_triggered(widget, function):
            widget.triggered.connect(function)
            if hasattr(function, "__doc__"):
                widget.setToolTip(function.__doc__.strip())

        def set_clicked(widget, function):
            widget.clicked.connect(function)
            if hasattr(function, "__doc__"):
                widget.setToolTip(function.__doc__.strip())

        set_clicked(self.geqdsk_file_browse_button, self.select_geqdsk_file)
        self.geqdsk_file_line_edit.editingFinished.connect(self.read_geqdsk)

        set_clicked(self.options_file_browse_button, self.select_options_file)
        self.options_file_line_edit.editingFinished.connect(self.read_options)

        set_clicked(self.run_button, self.run)
        set_triggered(self.action_Run, self.run)

        set_clicked(self.write_grid_button, self.write_grid)
        set_triggered(self.action_Write_grid, self.write_grid)
        self.write_grid_button.setEnabled(False)

        self.nonorthogonal_box.stateChanged.connect(self.set_nonorthogonal)

        set_clicked(self.regrid_button, self.regrid)
        set_triggered(self.action_Regrid, self.regrid)

        set_triggered(self.action_Revert, self.revert_options)
        set_triggered(self.action_Save, self.save_options)
        set_triggered(self.action_Save_as, self.save_options_as)
        set_triggered(self.action_New, self.new_options)
        set_triggered(self.action_Open, self.select_options_file)
        set_triggered(self.action_About, self.help_about)
        set_triggered(self.action_Preferences, self.open_preferences)

        self.action_Quit.triggered.connect(self.close)

        self.options = DEFAULT_OPTIONS
        self.gui_options = DEFAULT_GUI_OPTIONS
        self.filename = DEFAULT_OPTIONS_FILENAME

        self.search_bar.setPlaceholderText("Search options...")
        self.search_bar.textChanged.connect(self.search_options_form)
        self.search_bar.setToolTip(self.search_options_form.__doc__.strip())
        option_names = (
            set(BoutMesh.user_options_factory.defaults.keys()).union(
                set(tokamak.TokamakEquilibrium.user_options_factory.defaults.
                    keys())).union(
                        set(tokamak.TokamakEquilibrium.
                            nonorthogonal_options_factory.defaults.keys(
                            )  # noqa: E501
                            )))
        self.search_bar_completer = QCompleter(option_names)
        self.search_bar_completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.search_bar.setCompleter(self.search_bar_completer)

        self.options_form.cellChanged.connect(self.options_form_changed)
        self.options_form.itemDoubleClicked.connect(_table_item_edit_display)
        self.update_options_form()
Ejemplo n.º 7
0
class HypnotoadGui(QMainWindow, Ui_Hypnotoad):
    """A graphical interface for Hypnotoad"""
    def __init__(self):
        super().__init__(None)
        self.setupUi(self)

        try:
            self.menu_File.setToolTipsVisible(True)
            self.menu_Mesh.setToolTipsVisible(True)
            self.menu_Help.setToolTipsVisible(True)
        except AttributeError:
            pass

        self.plot_widget = MatplotlibWidget(self.plottingArea)

        def set_triggered(widget, function):
            widget.triggered.connect(function)
            if hasattr(function, "__doc__"):
                widget.setToolTip(function.__doc__.strip())

        def set_clicked(widget, function):
            widget.clicked.connect(function)
            if hasattr(function, "__doc__"):
                widget.setToolTip(function.__doc__.strip())

        set_clicked(self.geqdsk_file_browse_button, self.select_geqdsk_file)
        self.geqdsk_file_line_edit.editingFinished.connect(self.read_geqdsk)

        set_clicked(self.options_file_browse_button, self.select_options_file)
        self.options_file_line_edit.editingFinished.connect(self.read_options)

        set_clicked(self.run_button, self.run)
        set_triggered(self.action_Run, self.run)

        set_clicked(self.write_grid_button, self.write_grid)
        set_triggered(self.action_Write_grid, self.write_grid)
        self.write_grid_button.setEnabled(False)

        self.nonorthogonal_box.stateChanged.connect(self.set_nonorthogonal)

        set_clicked(self.regrid_button, self.regrid)
        set_triggered(self.action_Regrid, self.regrid)

        set_triggered(self.action_Revert, self.revert_options)
        set_triggered(self.action_Save, self.save_options)
        set_triggered(self.action_Save_as, self.save_options_as)
        set_triggered(self.action_New, self.new_options)
        set_triggered(self.action_Open, self.select_options_file)
        set_triggered(self.action_About, self.help_about)
        set_triggered(self.action_Preferences, self.open_preferences)

        self.action_Quit.triggered.connect(self.close)

        self.options = DEFAULT_OPTIONS
        self.gui_options = DEFAULT_GUI_OPTIONS
        self.filename = DEFAULT_OPTIONS_FILENAME

        self.search_bar.setPlaceholderText("Search options...")
        self.search_bar.textChanged.connect(self.search_options_form)
        self.search_bar.setToolTip(self.search_options_form.__doc__.strip())
        option_names = (
            set(BoutMesh.user_options_factory.defaults.keys()).union(
                set(tokamak.TokamakEquilibrium.user_options_factory.defaults.
                    keys())).union(
                        set(tokamak.TokamakEquilibrium.
                            nonorthogonal_options_factory.defaults.keys(
                            )  # noqa: E501
                            )))
        self.search_bar_completer = QCompleter(option_names)
        self.search_bar_completer.setCaseSensitivity(Qt.CaseInsensitive)
        self.search_bar.setCompleter(self.search_bar_completer)

        self.options_form.cellChanged.connect(self.options_form_changed)
        self.options_form.itemDoubleClicked.connect(_table_item_edit_display)
        self.update_options_form()

    def close(self):
        # Delete and garbage-collect hypnotoad objects here so that any ParallelMap
        # instances get deleted if they exists. ParallelMap.__del__() calls
        # terminate on the worker processes. Needs to happen before program exits
        # or program will hang waiting for parallel workers to finish (which they
        # never do because they are waiting for another job in
        # ParallelMap.worker_run()).
        if hasattr(self, "eq"):
            del self.eq
        if hasattr(self, "mesh"):
            del self.mesh
        gc.collect()
        super().close()

    def help_about(self):
        """About Hypnotoad"""

        about_text = __doc__.strip()
        about_text += f"\nVersion : {__version__}"

        about_box = QMessageBox(self)
        about_box.setText(about_text)
        about_box.exec_()

    def open_preferences(self):
        """GUI preferences and settings"""
        preferences_window = Preferences(self)
        preferences_window.exec_()

    def revert_options(self):
        """Revert the current options to the loaded file, or defaults if no
        file loaded

        """

        self.statusbar.showMessage("Reverting options", 2000)
        self.options = DEFAULT_OPTIONS

        options_filename = self.options_file_line_edit.text()

        if options_filename:
            self.read_options()
        else:
            self.options_form.setRowCount(0)
            self.update_options_form()

    def new_options(self):
        """New set of options"""

        self.options = DEFAULT_OPTIONS
        self.options_form.setRowCount(0)
        self.update_options_form()

    def save_options(self):
        """Save options to file"""

        self.statusbar.showMessage("Saving...", 2000)

        if not self.filename or self.filename == DEFAULT_OPTIONS_FILENAME:
            self.save_options_as()

        if not self.filename:
            self.filename = DEFAULT_OPTIONS_FILENAME
            return

        self.options_file_line_edit.setText(self.filename)

        options_to_save = self.options
        if self.gui_options["save_full_yaml"]:
            mesh_options = BoutMesh.user_options_factory.create(self.options)
            eq_options = tokamak.TokamakEquilibrium.user_options_factory.create(
                self.options)
            nonorth_options = (tokamak.TokamakEquilibrium.
                               nonorthogonal_options_factory.create(
                                   self.options))

            # This converts any numpy types to native Python using the tolist()
            # method of any numpy objects/types. Note this does return a scalar
            # and not a list for values that aren't arrays. Also remove any
            # private/magic keys
            options_to_save = {}
            for key, value in mesh_options.items():
                options_to_save[key] = getattr(value, "tolist",
                                               lambda: value)()
            for key, value in eq_options.items():
                options_to_save[key] = getattr(value, "tolist",
                                               lambda: value)()
            for key, value in nonorth_options.items():
                options_to_save[key] = getattr(value, "tolist",
                                               lambda: value)()

        with open(self.filename, "w") as f:
            yaml.dump(options_to_save, f)

    def save_options_as(self):
        """Save options to file with new filename"""

        if not self.filename:
            self.filename = DEFAULT_OPTIONS_FILENAME

        self.filename, _ = QFileDialog.getSaveFileName(self,
                                                       "Save grid to file",
                                                       self.filename,
                                                       filter=YAML_FILTER)

        if not self.filename:
            return

        # If there was no extension, add one, unless the file already exists
        path = pathlib.Path(self.filename)
        if not path.exists() and path.suffix == "":
            self.filename += ".yml"

        self.save_options()

    def update_options_form(self):
        """Update the widget values in the options form, based on the current
        values in self.options

        """

        filtered_options = copy.deepcopy(self.options)

        filtered_defaults = dict(BoutMesh.user_options_factory.defaults)
        filtered_defaults.update(
            tokamak.TokamakEquilibrium.user_options_factory.defaults)
        filtered_defaults.update(
            tokamak.TokamakEquilibrium.nonorthogonal_options_factory.defaults)

        # evaluate filtered_defaults using the values in self.options, so that any
        # expressions get evaluated
        filtered_default_values = dict(
            BoutMesh.user_options_factory.create(self.options))
        try:
            filtered_default_values.update(
                tokamak.TokamakEquilibrium.user_options_factory.create(
                    self.options))
            if not hasattr(self, "eq"):
                filtered_default_values.update(
                    tokamak.TokamakEquilibrium.nonorthogonal_options_factory.
                    create(self.options))
            else:
                # Use the object if it exists because some defaults are updated when the
                # Equilibrium is created
                filtered_default_values.update(
                    self.eq.nonorthogonal_options_factory.create(self.options))
        except (ValueError, TypeError) as e:
            self._popup_error_message(e)
            return

        # Skip options handled specially elsewhere
        filtered_options.pop("orthogonal", None)
        del filtered_defaults["orthogonal"]

        self.options_form.setSortingEnabled(False)
        self.options_form.cellChanged.disconnect(self.options_form_changed)
        self.options_form.setRowCount(len(filtered_defaults))

        for row, (key, value) in enumerate(sorted(filtered_defaults.items())):
            item0 = QTableWidgetItem(key)
            item0.setFlags(item0.flags() & ~Qt.ItemIsEditable)
            item0.setToolTip(value.doc)
            self.options_form.setItem(row, 0, item0)
            if key in filtered_options:
                value_to_set = str(filtered_options[key])
            else:
                value_to_set = f"{filtered_default_values[key]} (default)"
            item1 = QTableWidgetItem(value_to_set)
            item1.setToolTip(textwrap.fill(value.doc))
            self.options_form.setItem(row, 1, item1)

        self.options_form.horizontalHeader().setSectionResizeMode(
            QHeaderView.Stretch)
        self.options_form.setSortingEnabled(True)
        self.options_form.cellChanged.connect(self.options_form_changed)

    def options_form_changed(self, row, column):
        """Change the options form from the widget table"""

        item = self.options_form.item(row, column)

        if column == 0:
            # column 0 is not editable, so this should not be possible
            raise ValueError("Not allowed to change option names")
        else:
            key = self.options_form.item(row, 0).text()

            if item.text() == "":
                # Reset to default
                # Might be better to just keep the old value if nothing is passed, but
                # don't know how to get that
                if key in self.options:
                    del self.options[key]
                return

            self.options[key] = ast.literal_eval(item.text())

        self.update_options_form()

    def search_options_form(self, text):
        """Search for specific options"""

        for i in range(self.options_form.rowCount()):
            row = self.options_form.item(i, 0)

            matches = text.lower() in row.text().lower()
            self.options_form.setRowHidden(i, not matches)

    def select_options_file(self):
        """Choose a Hypnotoad options file to load"""

        filename, _ = QFileDialog.getOpenFileName(self,
                                                  "Open options file",
                                                  ".",
                                                  filter=YAML_FILTER)

        if (filename is None) or (filename == ""):
            return  # Cancelled
        if not os.path.exists(filename):
            self.write("Could not find " + filename)
            return

        self.options_file_line_edit.setText(filename)
        self.filename = filename
        self.read_options()
        self.nonorthogonal_box.setChecked(
            not self.options.get("orthogonal", True))

    def read_options(self):
        """Read the options file"""

        self.statusbar.showMessage("Reading options", 2000)
        options_filename = self.options_file_line_edit.text()

        # Save the existing options in case there is an error loading the options file
        original_options = self.options

        if options_filename:
            with open(options_filename, "r") as f:
                self.options = yaml.safe_load(f)

        possible_options = ([
            opt
            for opt in tokamak.TokamakEquilibrium.user_options_factory.defaults
        ] + [
            opt for opt in tokamak.TokamakEquilibrium.
            nonorthogonal_options_factory.defaults  # noqa: E501
        ] + [opt for opt in BoutMesh.user_options_factory.defaults])
        unused_options = [
            opt for opt in self.options if opt not in possible_options
        ]
        if unused_options != []:
            short_filename = pathlib.Path(options_filename).parts[-1]
            self._popup_error_message(
                f"Error: There were options in the input file that are not used: "
                f"{unused_options}. Cannot load {short_filename}.")
            self.options = original_options

        self.options_form.setRowCount(0)
        self.update_options_form()

    def select_geqdsk_file(self):
        """Choose a "geqdsk" equilibrium file to open"""

        filename, _ = QFileDialog.getOpenFileName(self, "Open geqdsk file",
                                                  ".")

        if (filename is None) or (filename == ""):
            return  # Cancelled
        if not os.path.exists(filename):
            self.write("Could not find " + filename)
            self.geqdsk_file_line_edit.setStyleSheet(
                f"QLineEdit {{ background-color: {COLOURS['red']} }}")
            return

        self.geqdsk_file_line_edit.setText(filename)
        self.geqdsk_file_line_edit.setStyleSheet("")

        self.read_geqdsk()

    def read_geqdsk(self):
        """Read the equilibrium file"""

        self.statusbar.showMessage("Reading geqdsk", 2000)
        geqdsk_filename = self.geqdsk_file_line_edit.text()

        if not os.path.exists(geqdsk_filename):
            self.geqdsk_file_line_edit.setStyleSheet(
                f"QLineEdit {{ background-color : {COLOURS['red']} }}")
            self.statusbar.showMessage(
                f"Could not find equilibrium file '{geqdsk_filename}'")
            return

        try:
            with open(geqdsk_filename, "rt") as f:
                self.eq = tokamak.read_geqdsk(
                    f,
                    settings=copy.deepcopy(self.options),
                    nonorthogonal_settings=copy.deepcopy(self.options),
                )
        except (ValueError, RuntimeError, func_timeout.FunctionTimedOut) as e:
            self._popup_error_message(e)
            return

        self.update_options_form()

        # Delete mesh if it exists, since we have a new self.eq object
        if hasattr(self, "mesh"):
            del self.mesh
        self.regrid_button.setEnabled(False)
        self.action_Regrid.setEnabled(False)

        self.plot_grid()

        self.nonorthogonal_box.setChecked(not self.options["orthogonal"])

    def run(self):
        """Run Hypnotoad and generate the grid"""

        if not hasattr(self, "eq"):
            self.statusbar.showMessage("Missing equilibrium file!")
            self.geqdsk_file_line_edit.setStyleSheet(
                f"QLineEdit {{ background-color: {COLOURS['red']} }}")
            return

        # Call read_geqdsk to recreate self.eq object in case any settings needed in
        # __init__ have been changed
        self.read_geqdsk()

        self.statusbar.showMessage("Running...")
        try:
            self.mesh = BoutMesh(self.eq, self.options)
        except (ValueError, SolutionError, func_timeout.FunctionTimedOut) as e:
            self._popup_error_message(e)
            return

        self.mesh.calculateRZ()
        self.statusbar.showMessage("Done!", 2000)

        self.plot_grid()

        self.write_grid_button.setEnabled(True)
        self.regrid_button.setEnabled(not self.options["orthogonal"])
        self.action_Regrid.setEnabled(not self.options["orthogonal"])

    def set_nonorthogonal(self, state):
        state = bool(state)
        self.options["orthogonal"] = not state
        self.update_options_form()

    def regrid(self):
        """Regrid a nonorthogonal grid after spacing settings are changed"""

        if not hasattr(self, "mesh"):
            self.statusbar.showMessage("Generate grid first!")
            self.geqdsk_file_line_edit.setStyleSheet(
                f"QLineEdit {{ background-color: {COLOURS['red']} }}")
            return

        self.statusbar.showMessage("Running...")

        try:
            self.mesh.redistributePoints(self.options)
            self.mesh.calculateRZ()
        except (ValueError, TypeError, func_timeout.FunctionTimedOut) as e:
            self._popup_error_message(e)
            return

        self.statusbar.showMessage("Done!", 2000)

        self.plot_grid(keep_limits=True)

    def write_grid(self):
        """Write generated mesh to file"""

        # Create all the geometrical quantities
        try:
            self.mesh.geometry()
        except (
                ValueError,
                TypeError,
                func_timeout.FunctionTimedOut,
                SolutionError,
        ) as e:
            self._popup_error_message(e)
            return

        if not hasattr(self, "mesh"):
            flags = QMessageBox.StandardButton.Ok
            QMessageBox.critical(self, "Error",
                                 "Can't write mesh to file; no mesh found!",
                                 flags)
            return

        filename, _ = QFileDialog.getSaveFileName(
            self,
            "Save grid to file",
            self.gui_options["grid_file"],
            filter=NETCDF_FILTER,
        )

        if not filename:
            return

        # If there was no extension, add one, unless the file already exists
        path = pathlib.Path(self.filename)
        if not path.exists() and path.suffix == "":
            self.filename += ".nc"

        self.mesh.writeGridfile(filename)

    def plot_grid(self, *, keep_limits=False):
        self.plot_widget.clear(keep_limits=keep_limits)

        if hasattr(self, "eq"):
            self.eq.plotPotential(ncontours=40, axis=self.plot_widget.axes)
            self.eq.plotWall(axis=self.plot_widget.axes)

        if hasattr(self, "mesh"):
            # mesh exists, so plot the grid points
            self.mesh.plotPoints(
                xlow=self.gui_options["plot_xlow"],
                ylow=self.gui_options["plot_ylow"],
                corners=self.gui_options["plot_corners"],
                ax=self.plot_widget.axes,
            )
        elif hasattr(self, "eq"):
            # no mesh, but do have equilibrium, so plot separatrices
            for region in self.eq.regions.values():
                self.plot_widget.axes.plot(
                    [p.R for p in region.points],
                    [p.Z for p in region.points],
                    marker="o",
                )
            self.plot_widget.axes.plot(*self.eq.x_points[0], "rx")

        self.plot_widget.canvas.draw()

    def _popup_error_message(self, error):
        error_message = QErrorMessage()
        error_message.showMessage(
            str(error) + "<br><br>" + traceback.format_exc())
        error_message.exec_()