import os import spyder.utils.encoding from modulefinder import ModuleFinder from spyder.plugins.editor.widgets.codeeditor import CodeEditor from PySide6.QtCore import Signal as pyqtSignal, QFileSystemWatcher, QTimer from PySide6.QtWidgets import QAction, QFileDialog from PySide6.QtGui import QFontDatabase from path import Path import sys from pyqtgraph.parametertree import Parameter from ..mixins import ComponentMixin from ..utils import get_save_filename, get_open_filename, confirm from ..icons import icon class Editor(CodeEditor, ComponentMixin): name = "Code Editor" # This signal is emitted whenever the currently-open file changes and # autoreload is enabled. triggerRerender = pyqtSignal(bool) sigFilenameChanged = pyqtSignal(str) preferences = Parameter.create( name="Preferences", children=[ {"name": "Font size", "type": "int", "value": 12}, {"name": "Autoreload", "type": "bool", "value": False}, {"name": "Autoreload delay", "type": "int", "value": 50}, { "name": "Autoreload: watch imported modules", "type": "bool", "value": False, }, {"name": "Line wrap", "type": "bool", "value": False}, { "name": "Color scheme", "type": "list", "values": ["Spyder", "Monokai", "Zenburn"], "value": "Spyder", }, ], ) EXTENSIONS = "py" def __init__(self, parent=None): self._watched_file = None super(Editor, self).__init__(parent) ComponentMixin.__init__(self) self.setup_editor( linenumbers=True, markers=True, edge_line=False, tab_mode=False, show_blanks=True, font=QFontDatabase.systemFont(QFontDatabase.FixedFont), language="Python", filename="", ) self._actions = { "File": [ QAction( icon("new"), "New", self, shortcut="ctrl+N", triggered=self.new ), QAction( icon("open"), "Open", self, shortcut="ctrl+O", triggered=self.open ), QAction( icon("save"), "Save", self, shortcut="ctrl+S", triggered=self.save ), QAction( icon("save_as"), "Save as", self, shortcut="ctrl+shift+S", triggered=self.save_as, ), QAction( icon("autoreload"), "Automatic reload and preview", self, triggered=self.autoreload, checkable=True, checked=False, objectName="autoreload", ), ] } for a in self._actions.values(): self.addActions(a) self._fixContextMenu() # autoreload support self._file_watcher = QFileSystemWatcher(self) # we wait for 50ms after a file change for the file to be written completely self._file_watch_timer = QTimer(self) self._file_watch_timer.setInterval(self.preferences["Autoreload delay"]) self._file_watch_timer.setSingleShot(True) self._file_watcher.fileChanged.connect( lambda val: self._file_watch_timer.start() ) self._file_watch_timer.timeout.connect(self._file_changed) self.updatePreferences() def _fixContextMenu(self): menu = self.menu menu.removeAction(self.run_cell_action) menu.removeAction(self.run_cell_and_advance_action) menu.removeAction(self.run_selection_action) menu.removeAction(self.re_run_last_cell_action) def updatePreferences(self, *args): self.set_color_scheme(self.preferences["Color scheme"]) font = self.font() font.setPointSize(self.preferences["Font size"]) self.set_font(font) self.findChild(QAction, "autoreload").setChecked(self.preferences["Autoreload"]) self._file_watch_timer.setInterval(self.preferences["Autoreload delay"]) self.toggle_wrap_mode(self.preferences["Line wrap"]) self._clear_watched_paths() self._watch_paths() def confirm_discard(self): if self.modified: rv = confirm( self, "Please confirm", "Current document is not saved - do you want to continue?", ) else: rv = True return rv def new(self): if not self.confirm_discard(): return self.set_text("") self.filename = "" self.reset_modified() def open(self): if not self.confirm_discard(): return curr_dir = Path(self.filename).abspath().dirname() fname = get_open_filename(self.EXTENSIONS, curr_dir) if fname != "": self.load_from_file(fname) def load_from_file(self, fname): self.set_text_from_file(fname) self.filename = fname self.reset_modified() def determine_encoding(self, fname): if os.path.exists(fname): # this function returns the encoding spyder used to read the file _, encoding = spyder.utils.encoding.read(fname) # spyder returns a -guessed suffix in some cases return encoding.replace("-guessed", "") else: return "utf-8" def save(self): if self._filename != "": if self.preferences["Autoreload"]: self._file_watcher.blockSignals(True) self._file_watch_timer.stop() encoding = self.determine_encoding(self._filename) encoded = self.toPlainText().encode(encoding) with open(self._filename, "wb") as f: f.write(encoded) if self.preferences["Autoreload"]: self._file_watcher.blockSignals(False) self.triggerRerender.emit(True) self.reset_modified() else: self.save_as() def save_as(self): fname = get_save_filename(self.EXTENSIONS) if fname != "": encoded = self.toPlainText().encode("utf-8") with open(fname, "wb") as f: f.write(encoded) self.filename = fname self.reset_modified() def _update_filewatcher(self): if self._watched_file and ( self._watched_file != self.filename or not self.preferences["Autoreload"] ): self._clear_watched_paths() self._watched_file = None if ( self.preferences["Autoreload"] and self.filename and self.filename != self._watched_file ): self._watched_file = self._filename self._watch_paths() @property def filename(self): return self._filename @filename.setter def filename(self, fname): self._filename = fname self._update_filewatcher() self.sigFilenameChanged.emit(fname) def _clear_watched_paths(self): paths = self._file_watcher.files() if paths: self._file_watcher.removePaths(paths) def _watch_paths(self): if Path(self._filename).exists(): self._file_watcher.addPath(self._filename) if self.preferences["Autoreload: watch imported modules"]: module_paths = self.get_imported_module_paths(self._filename) if module_paths: self._file_watcher.addPaths(module_paths) # callback triggered by QFileSystemWatcher def _file_changed(self): # neovim writes a file by removing it first so must re-add each time self._watch_paths() self.set_text_from_file(self._filename) self.triggerRerender.emit(True) # Turn autoreload on/off. def autoreload(self, enabled): self.preferences["Autoreload"] = enabled self._update_filewatcher() def reset_modified(self): self.document().setModified(False) @property def modified(self): return self.document().isModified() def saveComponentState(self, store): if self.filename != "": store.setValue(self.name + "/state", self.filename) def restoreComponentState(self, store): filename = store.value(self.name + "/state") if filename and self.filename == "": try: self.load_from_file(filename) except IOError: self._logger.warning(f"could not open {filename}") def get_imported_module_paths(self, module_path): finder = ModuleFinder([os.path.dirname(module_path)]) imported_modules = [] try: finder.run_script(module_path) except SyntaxError as err: self._logger.warning(f"Syntax error in {module_path}: {err}") except Exception as err: self._logger.warning( f"Cannot determine imported modules in {module_path}: {type(err).__name__} {err}" ) else: for module_name, module in finder.modules.items(): if module_name != "__main__": path = getattr(module, "__file__", None) if path is not None and os.path.isfile(path): imported_modules.append(path) return imported_modules if __name__ == "__main__": from PySide6.QtWidgets import QApplication app = QApplication(sys.argv) editor = Editor() editor.show() sys.exit(app.exec_())