From 5e6f869c7a3ee0ab198e131ca638518a206c9218 Mon Sep 17 00:00:00 2001 From: Martyn Gigg <martyn.gigg@gmail.com> Date: Mon, 15 Jan 2018 17:52:22 +0000 Subject: [PATCH] Add file saving ability to tabbed interpreter Refs #21251 --- .../mantidqt/widgets/codeeditor/execution.py | 15 ++-- .../widgets/codeeditor/interpreter.py | 88 +++++++++++++++++-- .../codeeditor/multifileinterpreter.py | 82 ++++++++++++++--- .../widgets/codeeditor/test/test_execution.py | 9 +- .../mantidqt/widgets/src/_widgetscore.sip | 3 + .../inc/MantidQtWidgets/Common/ScriptEditor.h | 11 ++- qt/widgets/common/src/ScriptEditor.cpp | 12 ++- 7 files changed, 181 insertions(+), 39 deletions(-) diff --git a/qt/python/mantidqt/widgets/codeeditor/execution.py b/qt/python/mantidqt/widgets/codeeditor/execution.py index 7367bcaa75f..e276ed73d1e 100644 --- a/qt/python/mantidqt/widgets/codeeditor/execution.py +++ b/qt/python/mantidqt/widgets/codeeditor/execution.py @@ -35,11 +35,10 @@ class PythonCodeExecution(QObject): sig_exec_error = Signal(object) sig_exec_progress = Signal(int) - def __init__(self, filename=None): + def __init__(self): """Initialize the object""" super(PythonCodeExecution, self).__init__() - self._filename = '<string>' if filename is None else filename self._globals_ns, self._locals_ns = None, None self.reset_context() @@ -63,31 +62,33 @@ class PythonCodeExecution(QObject): # create new context for execution self._globals_ns, self._locals_ns = {}, {} - def execute_async(self, code_str): + def execute_async(self, code_str, filename=None): """ Execute the given code string on a separate thread. This function returns as soon as the new thread starts :param code_str: A string containing code to execute - :param user_globals: A mutable mapping type to store global variables - :param user_locals: A mutable mapping type to store local variables + :param filename: See PythonCodeExecution.execute() :returns: The created async task """ # Stack is chopped on error to avoid the AsyncTask.run->self.execute calls appearing # as these are not useful for the user in this context - t = AsyncTask(self.execute, args=(code_str,), + t = AsyncTask(self.execute, args=(code_str,filename), stack_chop=2, success_cb=self._on_success, error_cb=self._on_error) t.start() return t - def execute(self, code_str): + def execute(self, code_str, filename=None): """Execute the given code on the calling thread within the provided context. :param code_str: A string containing code to execute + :param filename: An optional identifier specifying the file source of the code. If None then '<string>' + is used :raises: Any error that the code generates """ + filename = '<string>' if filename is None else filename compile(code_str, self.filename, mode='exec') sig_progress = self.sig_exec_progress diff --git a/qt/python/mantidqt/widgets/codeeditor/interpreter.py b/qt/python/mantidqt/widgets/codeeditor/interpreter.py index 4601c8673e6..8e0fbbfaa2a 100644 --- a/qt/python/mantidqt/widgets/codeeditor/interpreter.py +++ b/qt/python/mantidqt/widgets/codeeditor/interpreter.py @@ -22,7 +22,7 @@ import sys # 3rd party imports from qtpy.QtCore import QObject, Signal from qtpy.QtGui import QColor, QFontMetrics -from qtpy.QtWidgets import QStatusBar, QVBoxLayout, QWidget +from qtpy.QtWidgets import QFileDialog, QMessageBox, QStatusBar, QVBoxLayout, QWidget # local imports from mantidqt.widgets.codeeditor.editor import CodeEditor @@ -39,9 +39,61 @@ CURRENTLINE_BKGD_COLOR = QColor(247, 236, 248) TAB_WIDTH = 4 +class EditorIO(object): + + def __init__(self, editor): + self.editor = editor + + def ask_for_filename(self): + filename, _ = QFileDialog.getSaveFileName(self.editor, "Choose filename...") + return filename + + def save_if_required(self, confirm=True): + """Asks the user if the contents should be saved. + + :param confirm: If True then show a confirmation dialog first to check we should save + :returns: True if either saving was successful or no save was requested. Returns False if + the operation should be cancelled + """ + if confirm: + button = QMessageBox.question(self.editor, "", + "Save changes to document before closing?", + buttons=(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel), + defaultButton=QMessageBox.Cancel) + if button == QMessageBox.Yes: + return self.write() + elif button == QMessageBox.No: + return True + else: + # Cancelled + return False + else: + return self.write() + + def write(self): + filename = self.editor.fileName() + if not filename: + filename = self.ask_for_filename() + if not filename: + return False + self.editor.setFileName(filename) + + try: + with open(filename, 'w') as f: + f.write(self.editor.text()) + self.editor.setModified(False) + except IOError as exc: + QMessageBox.warning(self.editor, "", + "Error while saving '{}': {}".format(filename, str(exc))) + return False + else: + return True + + class PythonFileInterpreter(QWidget): sig_editor_modified = Signal(bool) + sig_filename_modified = Signal(str) def __init__(self, content=None, filename=None, parent=None): @@ -60,26 +112,43 @@ class PythonFileInterpreter(QWidget): layout.addWidget(self.status) self.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) - self._setup_editor(content) + self._setup_editor(content, filename) - self._presenter = PythonFileInterpreterPresenter(self, PythonCodeExecution(filename)) + self._presenter = PythonFileInterpreterPresenter(self, + PythonCodeExecution()) self.editor.modificationChanged.connect(self.sig_editor_modified) + self.editor.fileNameChanged.connect(self.sig_filename_modified) @property def filename(self): - return self._presenter.model.filename + return self.editor.fileName() + + def confirm_close(self): + """Confirm the widget can be closed. If the editor contents are modified then + a user can interject and cancel closing. + + :return: True if closing was considered successful, false otherwise + """ + return self.save(confirm=True) def execute_async(self): self._presenter.req_execute_async() + def save(self, confirm=False): + if self.editor.isModified(): + io = EditorIO(self.editor) + return io.save_if_required(confirm) + else: + return True + def set_editor_readonly(self, ro): self.editor.setReadOnly(ro) def set_status_message(self, msg): self.status.showMessage(msg) - def _setup_editor(self, default_content): + def _setup_editor(self, default_content, filename): editor = self.editor # use tabs not spaces for indentation @@ -95,9 +164,14 @@ class PythonFileInterpreter(QWidget): font_metrics = QFontMetrics(self.font()) editor.setMarginWidth(1, font_metrics.averageCharWidth()*3 + 12) - # fill with content if supplied + # fill with content if supplied and set source filename if default_content is not None: editor.setText(default_content) + if filename is not None: + editor.setFileName(filename) + editor.setModified(False) + else: + editor.setModified(True) class PythonFileInterpreterPresenter(QObject): @@ -138,7 +212,7 @@ class PythonFileInterpreterPresenter(QObject): return self.view.set_editor_readonly(True) self.view.set_status_message(RUNNING_STATUS_MSG) - return self.model.execute_async(code_str) + return self.model.execute_async(code_str, self.view.filename) def _get_code_for_execution(self): editor = self.view.editor diff --git a/qt/python/mantidqt/widgets/codeeditor/multifileinterpreter.py b/qt/python/mantidqt/widgets/codeeditor/multifileinterpreter.py index 765e4eeed0c..77624eb813a 100644 --- a/qt/python/mantidqt/widgets/codeeditor/multifileinterpreter.py +++ b/qt/python/mantidqt/widgets/codeeditor/multifileinterpreter.py @@ -16,16 +16,26 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import (absolute_import, unicode_literals) +# std imports +import os.path as osp + # 3rd party imports from qtpy.QtWidgets import QTabWidget, QVBoxLayout, QWidget # local imports from mantidqt.widgets.codeeditor.interpreter import PythonFileInterpreter -NEW_TAB_TITLE = 'temp.py' +NEW_TAB_TITLE = 'New' MODIFIED_MARKER = '*' +def _tab_title_and_toolip(filename): + if filename is None: + return NEW_TAB_TITLE, NEW_TAB_TITLE + else: + return osp.basename(filename), filename + + class MultiPythonFileInterpreter(QWidget): """Provides a tabbed widget for editing multiple files""" @@ -35,41 +45,85 @@ class MultiPythonFileInterpreter(QWidget): # attributes self.default_content = default_content - # layout + # widget setup self._tabs = QTabWidget(self) self._tabs.setMovable(True) + self._tabs.setTabsClosable(True) + self._tabs.tabCloseRequested.connect(self.close_tab) layout = QVBoxLayout() layout.addWidget(self._tabs) self.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) # add a single editor by default - self.append_new_editor() + self.append_new_editor(default_content) @property def editor_count(self): return self._tabs.count() - def append_new_editor(self): - interpreter = PythonFileInterpreter(self.default_content, + def append_new_editor(self, content=None, filename=None): + interpreter = PythonFileInterpreter(content, filename=filename, parent=self._tabs) - interpreter.sig_editor_modified.connect(self.update_tab_title) - self._tabs.addTab(interpreter, NEW_TAB_TITLE) - self.update_tab_title(modified=True) + # monitor future modifications + interpreter.sig_editor_modified.connect(self.mark_current_tab_modified) + interpreter.sig_filename_modified.connect(self.on_filename_modified) + + tab_title, tab_toolip = _tab_title_and_toolip(filename) + tab_idx = self._tabs.addTab(interpreter, tab_title) + self.mark_tab_modified(tab_idx, (filename is None)) + self._tabs.setTabToolTip(tab_idx, tab_toolip) + return tab_idx + + def close_tab(self, idx): + """Close the tab at the given index.""" + if idx >= self.editor_count: + return + editor = self.editor_at(idx) + if editor.confirm_close(): + self._tabs.removeTab(idx) + + # we never want an empty widget + if self.editor_count == 0: + self.append_new_editor(content=self.default_content) def current_editor(self): return self._tabs.currentWidget() + def editor_at(self, idx): + """Return the editor at the given index. Must be in range""" + return self._tabs.widget(idx) + def execute_current(self): """Execute content of the current file. If a selection is active then only this portion of code is executed""" self.current_editor().execute_async() - def update_tab_title(self, modified): + def on_filename_modified(self, filename): + title, tooltip = _tab_title_and_toolip(filename) + idx_cur = self._tabs.currentIndex() + self._tabs.setTabText(idx_cur, title) + self._tabs.setTabToolTip(idx_cur, tooltip) + + def open_file_in_new_tab(self, filepath): + """Open the existing file in a new tab in the editor + + :param filepath: A path to an existing file + """ + with open(filepath, 'r') as code_file: + content = code_file.read() + self._tabs.setCurrentIndex(self.append_new_editor(content=content, + filename=filepath)) + + def mark_current_tab_modified(self, modified): """Update the current tab title to indicate that the content has been modified""" - idx_cur = self._tabs.currentIndex() - title_cur = self._tabs.tabText(idx_cur) + self.mark_tab_modified(self._tabs.currentIndex(), modified) + + def mark_tab_modified(self, idx, modified): + """Update the tab title to indicate that the + content has been modified or not""" + title_cur = self._tabs.tabText(idx) if modified: if not title_cur.endswith(MODIFIED_MARKER): title_new = title_cur + MODIFIED_MARKER @@ -80,4 +134,8 @@ class MultiPythonFileInterpreter(QWidget): title_new = title_cur.rstrip('*') else: title_new = title_cur - self._tabs.setTabText(idx_cur, title_new) + self._tabs.setTabText(idx, title_new) + + def save_current_file(self): + """Save the current file""" + self.current_editor().save() diff --git a/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py b/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py index 28535d09b57..b8f2c4bb13d 100644 --- a/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py +++ b/qt/python/mantidqt/widgets/codeeditor/test/test_execution.py @@ -195,9 +195,10 @@ squared = sum*sum # ------------------------------------------------------------------------- # Filename checks # ------------------------------------------------------------------------- - def test_filename_included_in_traceback_if_defined(self): + def test_filename_included_in_traceback_if_supplied(self): code = """raise RuntimeError""" - executor, recv = self._run_async_code(code, filename='test.py') + filename = 'test.py' + executor, recv = self._run_async_code(code) self.assertTrue(recv.error_cb_called) self.assertEqual('test.py', recv.error_stack[0][0]) @@ -219,10 +220,8 @@ squared = sum*sum executor = PythonCodeExecution() self.assertRaises(expected_exc_type, executor.execute, code) - def _run_async_code(self, code, with_progress=False, filename=None): + def _run_async_code(self, code, with_progress=False): executor = PythonCodeExecution() - if filename is not None: - executor.filename = filename if with_progress: recv = ReceiverWithProgress() executor.sig_exec_progress.connect(recv.on_progess_update) diff --git a/qt/python/mantidqt/widgets/src/_widgetscore.sip b/qt/python/mantidqt/widgets/src/_widgetscore.sip index 7df091cbfb1..be14405d4f4 100644 --- a/qt/python/mantidqt/widgets/src/_widgetscore.sip +++ b/qt/python/mantidqt/widgets/src/_widgetscore.sip @@ -80,6 +80,7 @@ public: lineTo, indexTo); %End bool hasSelectedText() const; + bool isModified() const; bool isReadOnly() const; QString selectedText() const; QString text() const; @@ -89,6 +90,7 @@ public: void setIndentationsUseTabs(bool tabs); void setFileName(const QString &filename); void setMarginWidth(int margin, int width); + void setModified(bool m); void setReadOnly(bool ro); void setSelection(int lineFrom, int indexFrom, int lineTo, int indexTo); void setTabWidth(int width); @@ -98,6 +100,7 @@ public slots: void updateProgressMarker(int lineno, bool error); signals: + void fileNameChanged(const QString &fileName); void modificationChanged(bool m); private: diff --git a/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h b/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h index 24b1fa58397..54f39ef8f18 100644 --- a/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h +++ b/qt/widgets/common/inc/MantidQtWidgets/Common/ScriptEditor.h @@ -102,11 +102,8 @@ public: void keyPressEvent(QKeyEvent *event) override; /// The current filename inline QString fileName() const { return m_filename; } - /** - * Set a new file name - * @param filename :: The new filename - */ - inline void setFileName(const QString &filename) { m_filename = filename; } + /// Set a new file name + void setFileName(const QString &filename); /// Override so that ctrl + mouse wheel will zoom in and out void wheelEvent(QWheelEvent *e) override; @@ -127,7 +124,7 @@ public slots: /// Set the marker state void setMarkerState(bool enabled); /// Update the progress marker - void updateProgressMarker(int lineno, bool error=false); + void updateProgressMarker(int lineno, bool error = false); /// Mark the progress arrow as an error void markExecutingLineAsError(); /// Refresh the autocomplete information base on a new set of keywords @@ -149,6 +146,8 @@ signals: void textZoomedIn(); /// Emitted when a zoom out is requested void textZoomedOut(); + /// Notify that the filename has been modified + void fileNameChanged(const QString &fileName); protected: /// Write to the given device diff --git a/qt/widgets/common/src/ScriptEditor.cpp b/qt/widgets/common/src/ScriptEditor.cpp index 8c9423ae98e..39d16e8317e 100644 --- a/qt/widgets/common/src/ScriptEditor.cpp +++ b/qt/widgets/common/src/ScriptEditor.cpp @@ -205,8 +205,8 @@ QSize ScriptEditor::sizeHint() const { return QSize(600, 500); } void ScriptEditor::saveAs() { QString selectedFilter; QString filter = "Scripts (*.py *.PY);;All Files (*)"; - QString filename = QFileDialog::getSaveFileName(nullptr, "MantidPlot - Save", - "", filter, &selectedFilter); + QString filename = QFileDialog::getSaveFileName(nullptr, "Save file...", "", + filter, &selectedFilter); if (filename.isEmpty()) { throw SaveCancelledException(); @@ -279,6 +279,14 @@ void ScriptEditor::keyPressEvent(QKeyEvent *event) { forwardKeyPressToBase(event); } +/* + * @param filename The new filename + */ +void ScriptEditor::setFileName(const QString &filename) { + m_filename = filename; + emit fileNameChanged(filename); +} + /** Ctrl + Rotating the mouse wheel will increase/decrease the font size * */ -- GitLab