diff --git a/rascal2/dialogs/custom_file_editor.py b/rascal2/dialogs/custom_file_editor.py new file mode 100644 index 0000000..1836af2 --- /dev/null +++ b/rascal2/dialogs/custom_file_editor.py @@ -0,0 +1,99 @@ +"""Dialogs for editing custom files.""" + +import logging +from pathlib import Path + +from PyQt6 import Qsci, QtWidgets +from RATapi.utils.enums import Languages +from RATapi.wrappers import start_matlab + + +def edit_file(filename: str, language: Languages, parent: QtWidgets.QWidget): + """Edit a file in the file editor. + + Parameters + ---------- + filename : str + The name of the file to edit. + language : Languages + The language for dialog highlighting. + parent : QtWidgets.QWidget + The parent of this widget. + + """ + file = Path(filename) + if not file.is_file(): + logger = logging.getLogger("rascal_log") + logger.error("Attempted to edit a custom file which does not exist!") + return + + dialog = CustomFileEditorDialog(file, language, parent) + dialog.exec() + + +def edit_file_matlab(filename: str): + """Open a file in MATLAB.""" + loader = start_matlab() + + if loader is None: + logger = logging.getLogger("rascal_log") + logger.error("Attempted to edit a file in MATLAB engine, but `matlabengine` is not available.") + return + + engine = loader.result() + engine.edit(filename) + + +class CustomFileEditorDialog(QtWidgets.QDialog): + """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): + super().__init__(parent) + + self.setMinimumWidth(600) + self.setMinimumHeight(400) + + self.file = file + + self.editor = Qsci.QsciScintilla() + 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) + + self.editor.setText(self.file.read_text()) + + 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) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addWidget(save_button) + button_layout.addWidget(cancel_button) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.editor) + layout.addLayout(button_layout) + + self.setLayout(layout) + self.setWindowTitle(f"Edit {str(file)}") + + def save_file(self): + """Save and close the file.""" + self.file.write_text(self.editor.text()) + self.accept() diff --git a/rascal2/widgets/delegates.py b/rascal2/widgets/delegates.py index d55be11..09e001b 100644 --- a/rascal2/widgets/delegates.py +++ b/rascal2/widgets/delegates.py @@ -34,6 +34,41 @@ def setModelData(self, editor, model, index): model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole) +class CustomFileFunctionDelegate(QtWidgets.QStyledItemDelegate): + """Item delegate for choosing the function from a custom file.""" + + def __init__(self, parent): + super().__init__(parent) + self.widget = parent + + def createEditor(self, parent, option, index): + func_names = self.widget.model.func_names[ + index.siblingAtColumn(index.column() - 1).data(QtCore.Qt.ItemDataRole.DisplayRole) + ] + # we define the methods set_data and get_date + # so that setEditorData and setModelData don't need + # to know what kind of widget the editor is + if func_names is None: + editor = QtWidgets.QLineEdit(parent) + editor.set_data = editor.setText + editor.get_data = editor.text + else: + editor = QtWidgets.QComboBox(parent) + editor.addItems(func_names) + editor.set_data = editor.setCurrentText + editor.get_data = editor.currentText + + return editor + + def setEditorData(self, editor: QtWidgets.QWidget, index): + data = index.data(QtCore.Qt.ItemDataRole.DisplayRole) + editor.set_data(data) + + def setModelData(self, editor, model, index): + data = editor.get_data() + model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole) + + class ValueSpinBoxDelegate(QtWidgets.QStyledItemDelegate): """Item delegate for parameter values between a dynamic min and max. diff --git a/rascal2/widgets/inputs.py b/rascal2/widgets/inputs.py index b360ed5..ba37027 100644 --- a/rascal2/widgets/inputs.py +++ b/rascal2/widgets/inputs.py @@ -2,6 +2,7 @@ from enum import Enum from math import floor, log10 +from pathlib import Path from typing import Callable from pydantic.fields import FieldInfo @@ -29,6 +30,7 @@ def get_validated_input(field_info: FieldInfo, parent=None) -> QtWidgets.QWidget int: IntInputWidget, float: FloatInputWidget, Enum: EnumInputWidget, + Path: PathInputWidget, } for input_type, widget in class_widgets.items(): @@ -160,6 +162,25 @@ def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget: return editor +class PathInputWidget(BaseInputWidget): + """Input widget for paths.""" + + edit_signal = "pressed" + + def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget: + file_dialog = QtWidgets.QFileDialog(parent=self) + + def open_file(): + file = file_dialog.getOpenFileName(filter="*.m *.py *.cpp")[0] + if file: + browse_button.setText(file) + + browse_button = QtWidgets.QPushButton("Browse...", self) + browse_button.clicked.connect(lambda: open_file()) + + return browse_button + + class AdaptiveDoubleSpinBox(QtWidgets.QDoubleSpinBox): """A double spinbox which adapts to given numbers of decimals.""" diff --git a/rascal2/widgets/project/models.py b/rascal2/widgets/project/models.py index 408e565..4434294 100644 --- a/rascal2/widgets/project/models.py +++ b/rascal2/widgets/project/models.py @@ -1,14 +1,18 @@ """Models and widgets for project fields.""" +import contextlib +import re from enum import Enum +from pathlib import Path import pydantic import RATapi from PyQt6 import QtCore, QtGui, QtWidgets -from RATapi.utils.enums import Procedures +from RATapi.utils.enums import Languages, Procedures +import rascal2.widgets.delegates as delegates from rascal2.config import path_for -from rascal2.widgets.delegates import ParametersDelegate, ValidatedInputDelegate, ValueSpinBoxDelegate +from rascal2.dialogs.custom_file_editor import edit_file, edit_file_matlab class ClassListModel(QtCore.QAbstractTableModel): @@ -80,6 +84,7 @@ def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: return False if not self.edit_mode: self.parent.update_project() + self.dataChanged.emit(index, index) return True return False @@ -187,7 +192,7 @@ def set_item_delegates(self): """Set item delegates and open persistent editors for the table.""" for i, header in enumerate(self.model.headers): self.table.setItemDelegateForColumn( - i + 1, ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) + i + 1, delegates.ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) ) def append_item(self): @@ -275,10 +280,10 @@ class ParameterFieldWidget(ProjectFieldWidget): def set_item_delegates(self): for i, header in enumerate(self.model.headers): if header in ["min", "value", "max"]: - self.table.setItemDelegateForColumn(i + 1, ValueSpinBoxDelegate(header, self.table)) + self.table.setItemDelegateForColumn(i + 1, delegates.ValueSpinBoxDelegate(header, self.table)) else: self.table.setItemDelegateForColumn( - i + 1, ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) + i + 1, delegates.ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) ) def update_model(self, classlist): @@ -394,10 +399,10 @@ def set_item_delegates(self): if i in [1, self.model.columnCount() - 1]: header = self.model.headers[i - 1] self.table.setItemDelegateForColumn( - i, ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) + i, delegates.ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) ) else: - self.table.setItemDelegateForColumn(i, ParametersDelegate(self.project_widget, self.table)) + self.table.setItemDelegateForColumn(i, delegates.ParametersDelegate(self.project_widget, self.table)) def set_absorption(self, absorption: bool): """Set whether the classlist uses AbsorptionLayers. @@ -411,3 +416,158 @@ def set_absorption(self, absorption: bool): self.model.set_absorption(absorption) if self.model.edit_mode: self.edit() + + +class CustomFileModel(ClassListModel): + """Classlist model for custom files.""" + + def __init__(self, classlist: RATapi.ClassList, parent: QtWidgets.QWidget): + super().__init__(classlist, parent) + self.func_names = {} + self.headers.remove("path") + + def columnCount(self, parent=None) -> int: + return super().columnCount() + 1 + + def headerData(self, section, orientation, role=QtCore.Qt.ItemDataRole.DisplayRole): + if section == self.columnCount() - 1: + return None + return super().headerData(section, orientation, role) + + def flags(self, index): + flags = super().flags(index) + if index.column() in [0, self.columnCount() - 1]: + return QtCore.Qt.ItemFlag.NoItemFlags + if self.edit_mode: + flags |= QtCore.Qt.ItemFlag.ItemIsEditable + return flags + + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): + data = super().data(index, role) + if role == QtCore.Qt.ItemDataRole.DisplayRole and self.index_header(index) == "filename" and self.edit_mode: + if data == "": + return "Browse..." + return str(self.classlist[index.row()].path / data) + + return data + + def setData(self, index, value, role=QtCore.Qt.ItemDataRole.DisplayRole): + if self.index_header(index) == "filename": + file_path = Path(value) + row = index.row() + self.classlist[row].path = file_path.parent + self.classlist[row].filename = str(file_path.name) + + # auto-set language from file extension if possible + # & get file names for dropdown on Python + extension = file_path.suffix + match extension: + case ".py": + language = Languages.Python + # the regex: + # (?:^|\n) means 'match start of the string (i.e. the file) or a newline' + # (\S+) means 'capture one or more non-whitespace characters' + # so the regex captures a word between 'def ' and '(', i.e. a function name + func_names = re.findall(r"(?:^|\n)def (\S+)\(", file_path.read_text()) + case ".m": + language = Languages.Matlab + func_names = None + case ".dll" | ".so" | ".dylib": + language = Languages.Cpp + func_names = None + case _: + language = None + func_names = None + self.func_names[value] = func_names + if func_names is not None: + self.classlist[row].function_name = func_names[0] + if language is not None: + self.classlist[row].language = language + + self.dataChanged.emit(index, index) + return True + + return super().setData(index, value, role) + + def append_item(self): + """Append an item to the ClassList.""" + self.classlist.append(self.item_type(filename="", path="/")) + self.endResetModel() + + def index_header(self, index): + if index.column() == self.columnCount() - 1: + return None + return super().index_header(index) + + +class CustomFileWidget(ProjectFieldWidget): + classlist_model = CustomFileModel + + def update_model(self, classlist): + super().update_model(classlist) + self.table.hideColumn(self.model.columnCount() - 1) + + def edit(self): + super().edit() + edit_file_column = self.model.columnCount() - 1 + self.table.showColumn(edit_file_column) + # disconnect from old table's buttons so they don't create dangling references + # if no connections currently exist (i.e. table empty), disconnect() raises a TypeError + with contextlib.suppress(TypeError): + self.model.dataChanged.disconnect() + for i in range(0, self.model.rowCount()): + self.table.setIndexWidget(self.model.index(i, edit_file_column), self.make_edit_button(i)) + + def make_edit_button(self, index): + button = QtWidgets.QPushButton("Edit File", self.table) + q_scintilla_action = QtGui.QAction("Edit in RasCAL-2...", self.table) + q_scintilla_action.triggered.connect( + lambda: edit_file( + self.model.classlist[index].path / self.model.classlist[index].filename, + self.model.classlist[index].language, + self, + ) + ) + matlab_action = QtGui.QAction("Edit in MATLAB...", self.table) + matlab_action.triggered.connect( + lambda: edit_file_matlab(self.model.classlist[index].path / self.model.classlist[index].filename) + ) + menu = QtWidgets.QMenu(self.table) + menu.addActions([q_scintilla_action, matlab_action]) + + def setup_button(): + """Check whether the button should be editable and set it up for the right language.""" + language = self.model.data(self.model.index(index, self.model.headers.index("language") + 1)) + with contextlib.suppress(TypeError): + button.pressed.disconnect() + if language == Languages.Matlab: + button.setMenu(menu) + button.pressed.connect(button.showMenu) + else: + button.setMenu(None) + button.pressed.connect( + lambda: edit_file( + self.model.classlist[index].path / self.model.classlist[index].filename, + self.model.classlist[index].language, + self, + ) + ) + + editable = (language != Languages.Cpp) and ( + self.model.data(self.model.index(index, self.model.headers.index("filename") + 1)) != "Browse..." + ) + button.setEnabled(editable) + + setup_button() + self.model.dataChanged.connect(lambda: setup_button()) + + return button + + def set_item_delegates(self): + super().set_item_delegates() + filename_index = self.model.headers.index("filename") + 1 + function_index = self.model.headers.index("function_name") + 1 + self.table.setItemDelegateForColumn( + filename_index, delegates.ValidatedInputDelegate(self.model.item_type.model_fields["path"], self.table) + ) + self.table.setItemDelegateForColumn(function_index, delegates.CustomFileFunctionDelegate(self)) diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index 908d18c..874dc69 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -7,7 +7,7 @@ from RATapi.utils.enums import Calculations, Geometries, LayerModels from rascal2.config import path_for -from rascal2.widgets.project.models import LayerFieldWidget, ParameterFieldWidget, ProjectFieldWidget +from rascal2.widgets.project.models import CustomFileWidget, LayerFieldWidget, ParameterFieldWidget, ProjectFieldWidget class ProjectWidget(QtWidgets.QWidget): @@ -38,6 +38,7 @@ def __init__(self, parent): "Data": [], "Backgrounds": [], "Contrasts": [], + "Custom Files": ["custom_files"], "Domains": [], } @@ -362,6 +363,8 @@ def __init__(self, fields: list[str], parent, edit_mode: bool = False): self.tables[field] = ParameterFieldWidget(field, self) elif field == "layers": self.tables[field] = LayerFieldWidget(field, self) + elif field == "custom_files": + self.tables[field] = CustomFileWidget(field, self) else: self.tables[field] = ProjectFieldWidget(field, self) layout.addWidget(self.tables[field]) diff --git a/requirements.txt b/requirements.txt index 71efc5f..9d4adee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ PyQt6==6.7.0 PyQt6-Qt6==6.7.2 RATapi==0.0.0.dev3 pydantic==2.8.2 +PyQt6-QScintilla==2.14.1