diff --git a/rascal2/config.py b/rascal2/config.py index a7f90958..83300931 100644 --- a/rascal2/config.py +++ b/rascal2/config.py @@ -120,7 +120,7 @@ def run_matlab(ready_event, close_event, engine_output): eng.quit() -def get_matlab_engine(engine_ready, engine_output, is_local=False): +def get_matlab_engine(engine_ready, engine_output): """Get a MATLAB engine from the MatlabHelper or exception if no engine is available. Parameters @@ -129,8 +129,6 @@ def get_matlab_engine(engine_ready, engine_output, is_local=False): An event to inform listeners that MATLAB is ready. engine_output : multiprocessing.Manager.list A list with the name of MATLAB engine instance or an exception from the MatlabHelper. - is_local : bool, default False - Indicates a local engine should be created other connect ratapi. Returns ------- @@ -143,17 +141,13 @@ def get_matlab_engine(engine_ready, engine_output, is_local=False): if engine_output: if isinstance(engine_output[0], bytes): engine_name = engine_output[0].decode("utf-8") - if is_local: - import matlab.engine - engine_future = matlab.engine.connect_matlab(engine_name, background=True) - else: - import ratapi + import ratapi - engine_future = ratapi.wrappers.use_shared_matlab( - engine_name, - "Error occurred when connecting to MATLAB, please ensure MATLAB is installed and set up properly.", - ) + engine_future = ratapi.wrappers.use_shared_matlab( + engine_name, + "Error occurred when connecting to MATLAB, please ensure MATLAB is installed and set up properly.", + ) return engine_future elif isinstance(engine_output[0], Exception): @@ -208,7 +202,7 @@ def get_local_engine(self): if self.__engine is not None: return self.__engine - result = get_matlab_engine(self.ready_event, self.engine_output, True) + result = get_matlab_engine(self.ready_event, self.engine_output) if isinstance(result, Exception): raise result diff --git a/rascal2/dialogs/custom_file_editor.py b/rascal2/dialogs/custom_file_editor.py index 7410b34e..eea963bf 100644 --- a/rascal2/dialogs/custom_file_editor.py +++ b/rascal2/dialogs/custom_file_editor.py @@ -26,8 +26,10 @@ def edit_file(filename: str, language: Languages, parent: QtWidgets.QWidget): LOGGER.error("Attempted to edit a custom file which does not exist!") return - dialog = CustomFileEditorDialog(file, language, parent) - dialog.exec() + dialog = CustomFileEditorDialog(parent) + dialog.open_file(file, language) + dialog.setModal(False) + dialog.show() def edit_file_matlab(filename: str): @@ -41,25 +43,34 @@ def edit_file_matlab(filename: str): engine.edit(str(filename)) -class CustomFileEditorDialog(QtWidgets.QDialog): +class Singleton(type(QtWidgets.QDialog), type): + """Metaclass used to create a PyQt singleton.""" + + def __init__(cls, name, bases, cls_dict): + super().__init__(name, bases, cls_dict) + cls._instance = None + + def __call__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__call__(*args, **kwargs) + return cls._instance + + +class CustomFileEditorDialog(QtWidgets.QDialog, metaclass=Singleton): """Dialog for editing custom files. Parameters ---------- - file : pathlib.Path - The file to edit. - language : Languages - The language for dialog highlighting. parent : QtWidgets.QWidget The parent of this widget. """ - def __init__(self, file, language, parent): + def __init__(self, parent): super().__init__(parent) - self.file = file - + self.file = None + self.unchanged_text = "" self.editor = Qsci.QsciScintilla() self.editor.setBraceMatching(Qsci.QsciScintilla.BraceMatch.SloppyBraceMatch) self.editor.setCaretLineVisible(True) @@ -73,39 +84,26 @@ def __init__(self, file, language, parent): self.editor.setAutoIndent(True) self.editor.setTabWidth(4) - match language: - case Languages.Python: - self.editor.setLexer(Qsci.QsciLexerPython(self.editor)) - case Languages.Matlab: - self.editor.setLexer(Qsci.QsciLexerMatlab(self.editor)) - case _: - self.editor.setLexer(None) - - # Set the default font - font = QtGui.QFont("Courier", 10) - font.setFixedPitch(True) + font = self.default_font self.editor.setFont(font) - # Margin 0 is used for line numbers font_metrics = QtGui.QFontMetrics(font) self.editor.setMarginsFont(font) self.editor.setMarginWidth(0, font_metrics.horizontalAdvance("00000") + 6) self.editor.setMarginLineNumbers(0, True) self.editor.setMarginsBackgroundColor(QtGui.QColor("#cccccc")) - - if self.editor.lexer() is not None: - self.editor.lexer().setFont(font) - self.editor.setText(self.file.read_text()) + self.editor.textChanged.connect(self.show_modified) save_button = QtWidgets.QPushButton("Save", self) save_button.clicked.connect(self.save_file) - cancel_button = QtWidgets.QPushButton("Cancel", self) - cancel_button.clicked.connect(self.reject) + close_button = QtWidgets.QPushButton("Close", self) + close_button.clicked.connect(self.reject) button_layout = QtWidgets.QHBoxLayout() button_layout.addStretch(1) button_layout.addWidget(save_button) - button_layout.addWidget(cancel_button) + button_layout.addWidget(close_button) + button_layout.addSpacing(10) layout = QtWidgets.QVBoxLayout() layout.addWidget(self.editor) @@ -115,10 +113,85 @@ def __init__(self, file, language, parent): self.setMinimumWidth(800) self.setMinimumHeight(600) layout.setContentsMargins(0, 0, 0, 0) + + @property + def default_font(self): + """Return default editor font. + + Returns + ------- + font : QtGui.QFont + The default font. + + """ + # Set the default font + font = QtGui.QFont("Courier", 10) + font.setFixedPitch(True) + return font + + @property + def is_modified(self): + """Return if document is modified. + + Returns + ------- + modified : bool + Indicates if document is modified. + + """ + return self.unchanged_text != self.editor.text() + + def show_modified(self): + """Show modified state in window title.""" + pre = "* " if self.is_modified else "" + self.setWindowTitle(f"{pre}Edit {str(self.file)}") + + def open_file(self, file, language): + """Open a custom file. + + Parameters + ---------- + file : pathlib.Path + The file to edit. + language : Languages + The language for dialog highlighting. + """ + if file == self.file: + return # file is already opened + + if self.is_modified: + result = QtWidgets.QMessageBox.question( + self, + "Save File", + "Do you want to save changes to this file?", + QtWidgets.QMessageBox.StandardButton.Discard | QtWidgets.QMessageBox.StandardButton.Save, + QtWidgets.QMessageBox.StandardButton.Save, + ) + if result == QtWidgets.QMessageBox.StandardButton.Save: + self.save_file() + + self.file = file self.setWindowTitle(f"Edit {str(file)}") + match language: + case Languages.Python: + self.editor.setLexer(Qsci.QsciLexerPython(self.editor)) + case Languages.Matlab: + self.editor.setLexer(Qsci.QsciLexerMatlab(self.editor)) + case _: + self.editor.setLexer(None) + + if self.editor.lexer() is not None: + self.editor.lexer().setFont(self.default_font) + self.unchanged_text = self.file.read_text() + self.editor.setText(self.unchanged_text) + self.editor.setModified(False) + self.setWindowModified(False) def save_file(self): - """Save and close the file.""" + """Save the custom file.""" + if not self.is_modified: + return + if self.file.is_relative_to(EXAMPLES_PATH): message = "Files cannot be saved into the examples directory, please copy the file to another directory." QtWidgets.QMessageBox.warning(self, "Save File", message, QtWidgets.QMessageBox.StandardButton.Ok) @@ -126,8 +199,13 @@ def save_file(self): try: self.file.write_text(self.editor.text()) - self.accept() + self.unchanged_text = self.editor.text() + self.show_modified() except OSError as ex: message = f"Failed to save custom file to {self.file}.\n" LOGGER.error(message, exc_info=ex) QtWidgets.QMessageBox.critical(self, "Save File", message, QtWidgets.QMessageBox.StandardButton.Ok) + + def reject(self): + CustomFileEditorDialog._instance = None + super().reject() diff --git a/rascal2/ui/presenter.py b/rascal2/ui/presenter.py index def2cbe9..3dcf7811 100644 --- a/rascal2/ui/presenter.py +++ b/rascal2/ui/presenter.py @@ -5,7 +5,7 @@ import ratapi as rat import ratapi.wrappers -from rascal2.config import LOGGER, MatlabHelper, get_matlab_engine +from rascal2.config import LOGGER, MatlabHelper from rascal2.core import commands from rascal2.core.enums import UnsavedReply from rascal2.core.runner import LogData, RATRunner @@ -190,9 +190,7 @@ def quick_run(self, project=None): [file.language == "matlab" for file in self.model.project.custom_files] ): matlab_helper = MatlabHelper() - result = get_matlab_engine(matlab_helper.ready_event, matlab_helper.engine_output) - if isinstance(result, Exception): - raise result + matlab_helper.get_local_engine() return rat.run(project, rat.Controls(display="off"))[1] def run(self): diff --git a/rascal2/widgets/delegates.py b/rascal2/widgets/delegates.py index 2dfc5f05..0fafe2d8 100644 --- a/rascal2/widgets/delegates.py +++ b/rascal2/widgets/delegates.py @@ -108,11 +108,6 @@ def createEditor(self, parent, option, index): max_val = float("inf") min_val = -float("inf") - if self.field in ["min", "value"]: - max_val = index.siblingAtColumn(index.column() + 1).data(QtCore.Qt.ItemDataRole.DisplayRole) - if self.field in ["value", "max"]: - min_val = index.siblingAtColumn(index.column() - 1).data(QtCore.Qt.ItemDataRole.DisplayRole) - widget.setMinimum(min_val) widget.setMaximum(max_val) diff --git a/rascal2/widgets/project/tables.py b/rascal2/widgets/project/tables.py index 74d85364..0d9439c7 100644 --- a/rascal2/widgets/project/tables.py +++ b/rascal2/widgets/project/tables.py @@ -89,7 +89,7 @@ def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: if role == QtCore.Qt.ItemDataRole.EditRole or role == QtCore.Qt.ItemDataRole.CheckStateRole: row = index.row() param = self.index_header(index) - if self.index_header(index) == "fit": + if param == "fit": value = QtCore.Qt.CheckState(value) == QtCore.Qt.CheckState.Checked if param is not None: current_value = getattr(self.classlist[index.row()], param) @@ -356,6 +356,38 @@ def flags(self, index): return flags + def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: + param = self.index_header(index) + if param == "min": + min_value = value + value_model_index = index.siblingAtColumn(index.column() + 1) + max_model_index = index.siblingAtColumn(index.column() + 2) + + if min_value > max_model_index.data(QtCore.Qt.ItemDataRole.DisplayRole): + super().setData(max_model_index, min_value, role) + if min_value > value_model_index.data(QtCore.Qt.ItemDataRole.DisplayRole): + super().setData(value_model_index, min_value, role) + + elif param == "value": + min_model_index = index.siblingAtColumn(index.column() - 1) + actual_value = value + max_model_index = index.siblingAtColumn(index.column() + 1) + if actual_value < min_model_index.data(QtCore.Qt.ItemDataRole.DisplayRole): + super().setData(min_model_index, actual_value, role) + if actual_value > max_model_index.data(QtCore.Qt.ItemDataRole.DisplayRole): + super().setData(max_model_index, actual_value, role) + + elif param == "max": + min_model_index = index.siblingAtColumn(index.column() - 2) + value_model_index = index.siblingAtColumn(index.column() - 1) + max_value = value + if max_value < min_model_index.data(QtCore.Qt.ItemDataRole.DisplayRole): + super().setData(min_model_index, max_value, role) + if max_value < value_model_index.data(QtCore.Qt.ItemDataRole.DisplayRole): + super().setData(value_model_index, max_value, role) + + return super().setData(index, value, role) + class ParameterFieldWidget(ProjectFieldWidget): """Subclass of field widgets for parameters.""" diff --git a/tests/dialogs/test_custom_file_editor.py b/tests/dialogs/test_custom_file_editor.py index dfee58c1..8c3bc09c 100644 --- a/tests/dialogs/test_custom_file_editor.py +++ b/tests/dialogs/test_custom_file_editor.py @@ -17,18 +17,21 @@ @pytest.fixture def custom_file_dialog(): """Fixture for a custom file dialog.""" + dlg = CustomFileEditorDialog(parent) + yield dlg + dlg.reject() - def _dialog(language, tmpdir): - file = Path(tmpdir, "test_file") - file.write_text("Test text for a test dialog!") - dlg = CustomFileEditorDialog(file, language, parent) - return dlg - - return _dialog +@pytest.fixture +def temp_file(): + with tempfile.NamedTemporaryFile("w+", suffix=".py", delete=False) as f: + f.write("Test text for a test dialog!") + f.flush() + yield Path(f.name) + f.close() -@patch("rascal2.dialogs.custom_file_editor.CustomFileEditorDialog.exec") +@patch("rascal2.dialogs.custom_file_editor.CustomFileEditorDialog.show") def test_edit_file(exec_mock): """Test that the dialog is executed when edit_file() is called on a valid file.""" with tempfile.TemporaryDirectory() as tmp: @@ -90,21 +93,77 @@ def test_edit_no_matlab_engine(mock_matlab, caplog): "language, expected_lexer", [(Languages.Python, Qsci.QsciLexerPython), (Languages.Matlab, Qsci.QsciLexerMatlab), (None, type(None))], ) -def test_dialog_init(custom_file_dialog, language, expected_lexer): +def test_dialog_init(custom_file_dialog, temp_file, language, expected_lexer): """Ensure the custom file editor is set up correctly.""" - with tempfile.TemporaryDirectory() as tmp: - dialog = custom_file_dialog(language, tmp) + custom_file_dialog.open_file(temp_file, language) - assert isinstance(dialog.editor.lexer(), expected_lexer) - assert dialog.editor.text() == "Test text for a test dialog!" + assert isinstance(custom_file_dialog.editor.lexer(), expected_lexer) + assert custom_file_dialog.editor.text() == "Test text for a test dialog!" -def test_dialog_save(custom_file_dialog): +@patch("rascal2.dialogs.custom_file_editor.LOGGER") +@patch("rascal2.dialogs.custom_file_editor.QtWidgets.QMessageBox") +def test_dialog_save(mock_msg_box, mock_logger, custom_file_dialog): """Text changes to the editor are saved to the file when save_file is called.""" - with tempfile.TemporaryDirectory() as tmp: - dialog = custom_file_dialog(Languages.Python, tmp) + temp_file = MagicMock() + temp_file.read_text = MagicMock(return_value="This is a test") + + custom_file_dialog.open_file(temp_file, Languages.Python) + + assert not custom_file_dialog.is_modified + + # No changes so no save + custom_file_dialog.save_file() + temp_file.write_text.assert_not_called() + custom_file_dialog.unchanged_text = temp_file.read_text() + + custom_file_dialog.editor.setText("New test text...") + + assert custom_file_dialog.is_modified - dialog.editor.setText("New test text...") - dialog.save_file() + temp_file.is_relative_to = MagicMock(return_value=True) # file is relative to example dir + # No save in example dir + custom_file_dialog.save_file() + mock_msg_box.warning.assert_called_once() + temp_file.write_text.assert_not_called() + custom_file_dialog.unchanged_text = temp_file.read_text() - assert Path(tmp, "test_file").read_text() == "New test text..." + temp_file.is_relative_to = MagicMock(return_value=False) + custom_file_dialog.save_file() + temp_file.write_text.assert_called_once() + assert not custom_file_dialog.is_modified + custom_file_dialog.unchanged_text = temp_file.write_text.call_args[0] + + temp_file.write_text = MagicMock(side_effect=OSError) + custom_file_dialog.save_file() + mock_logger.error.assert_called_once() + mock_msg_box.critical.assert_called_once() + + +@patch("rascal2.dialogs.custom_file_editor.QtWidgets.QMessageBox.question") +def test_save_changes_when_opening_file(mock_msg_box, custom_file_dialog, temp_file): + """Text changes to the editor are saved to the file when save_file is called.""" + custom_file_dialog.open_file(temp_file, Languages.Python) + + custom_file_dialog.editor.setText("New test text...") + + # Opening the same file should not trigger a save warning + custom_file_dialog.open_file(temp_file, Languages.Python) + + mock_msg_box.assert_not_called() + assert temp_file.read_text() != "New test text..." + + with tempfile.TemporaryDirectory() as tmp: + new_file = Path(tmp, "filename.py") + new_file.write_text("This is a new file") + mock_msg_box.return_value = QtWidgets.QMessageBox.StandardButton.Save + # Changing the file should save old file if user selects save in msg box + custom_file_dialog.open_file(new_file, Languages.Python) + mock_msg_box.assert_called_once() + assert temp_file.read_text() == "New test text..." + + custom_file_dialog.editor.setText("Another test text...") + mock_msg_box.return_value = QtWidgets.QMessageBox.StandardButton.Discard + # Changes should be discarded as user selected discard in msg box + custom_file_dialog.open_file(temp_file, Languages.Python) + assert new_file.read_text() == "This is a new file"