diff --git a/python/PyQt6/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in b/python/PyQt6/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in index 37174ca39cb9..03aee51b2777 100644 --- a/python/PyQt6/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in +++ b/python/PyQt6/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in @@ -90,6 +90,15 @@ This method calls :py:func:`QgsCodeEditor.clearWarnings()`, but also removes highlights from the widget scrollbars at the warning locations. .. seealso:: :py:func:`addWarning` +%End + + QString filePath() const; +%Docstring +Returns the widget's associated file path. + +.. seealso:: :py:func:`setFilePath` + +.. seealso:: :py:func:`filePathChanged` %End public slots: @@ -136,6 +145,24 @@ Triggers a find operation, using the default behavior. This will automatically open the search bar and start a find operation using the default behavior, e.g. searching for any selected text in the code editor. +%End + + void setFilePath( const QString &path ); +%Docstring +Sets the widget's associated file ``path``. + +.. seealso:: :py:func:`filePathChanged` + +.. seealso:: :py:func:`filePath` +%End + + bool openInExternalEditor(); +%Docstring +Attempts to opens the script from the editor in an external text editor. + +This requires that the widget has an associated :py:func:`~QgsCodeEditorWidget.filePath` set. + +:return: ``True`` if the file was opened successfully. %End signals: @@ -143,6 +170,15 @@ the default behavior, e.g. searching for any selected text in the code editor. void searchBarToggled( bool visible ); %Docstring Emitted when the visibility of the search bar is changed. +%End + + void filePathChanged( const QString &path ); +%Docstring +Emitted when the widget's associated file path is changed. + +.. seealso:: :py:func:`setFilePath` + +.. seealso:: :py:func:`filePath` %End }; diff --git a/python/console/console.py b/python/console/console.py index adc5ff55873f..612de970a146 100644 --- a/python/console/console.py +++ b/python/console/console.py @@ -18,6 +18,7 @@ Some portions of code were taken from https://code.google.com/p/pydee/ """ import os +import subprocess from qgis.PyQt.QtCore import Qt, QTimer, QCoreApplication, QSize, QByteArray, QFileInfo, QUrl, QDir from qgis.PyQt.QtWidgets import QToolBar, QToolButton, QWidget, QSplitter, QTreeWidget, QAction, QFileDialog, QCheckBox, QSizePolicy, QMenu, QGridLayout, QApplication, QShortcut @@ -587,12 +588,7 @@ def reformatCode(self): def openScriptFileExtEditor(self): tabWidget = self.tabEditorWidget.currentWidget() - path = tabWidget.path - import subprocess - try: - subprocess.Popen([os.environ['EDITOR'], path]) - except KeyError: - QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + tabWidget.open_in_external_editor() def openScriptFile(self): settings = QgsSettings() @@ -604,7 +600,7 @@ def openScriptFile(self): for pyFile in fileList: for i in range(self.tabEditorWidget.count()): tabWidget = self.tabEditorWidget.widget(i) - if tabWidget.path == pyFile: + if tabWidget.file_path() == pyFile: self.tabEditorWidget.setCurrentWidget(tabWidget) break else: @@ -621,7 +617,7 @@ def saveScriptFile(self): tabWidget.save() except (IOError, OSError) as error: msgText = QCoreApplication.translate('PythonConsole', - 'The file {0} could not be saved. Error: {1}').format(tabWidget.path, + 'The file {0} could not be saved. Error: {1}').format(tabWidget.file_path(), error.strerror) self.callWidgetMessageBarEditor(msgText, Qgis.MessageLevel.Critical) @@ -629,13 +625,13 @@ def saveAsScriptFile(self, index=None): tabWidget = self.tabEditorWidget.currentWidget() if not index: index = self.tabEditorWidget.currentIndex() - if not tabWidget.path: + if not tabWidget.file_path(): fileName = self.tabEditorWidget.tabText(index).replace('*', '') + '.py' folder = QgsSettings().value("pythonConsole/lastDirPath", QDir.homePath()) pathFileName = os.path.join(folder, fileName) fileNone = True else: - pathFileName = tabWidget.path + pathFileName = tabWidget.file_path() fileNone = False saveAsFileTr = QCoreApplication.translate("PythonConsole", "Save File As") filename, filter = QFileDialog.getSaveFileName(self, @@ -648,13 +644,13 @@ def saveAsScriptFile(self, index=None): tabWidget.save(filename) except (IOError, OSError) as error: msgText = QCoreApplication.translate('PythonConsole', - 'The file {0} could not be saved. Error: {1}').format(tabWidget.path, + 'The file {0} could not be saved. Error: {1}').format(tabWidget.file_path(), error.strerror) self.callWidgetMessageBarEditor(msgText, Qgis.MessageLevel.Critical) if fileNone: - tabWidget.path = None + tabWidget.set_file_path(None) else: - tabWidget.path = pathFileName + tabWidget.set_file_path(pathFileName) return if not fileNone: diff --git a/python/console/console_editor.py b/python/console/console_editor.py index cc58926d97ff..d03afa459ac5 100644 --- a/python/console/console_editor.py +++ b/python/console/console_editor.py @@ -93,7 +93,6 @@ def __init__(self, self.tab_widget: EditorTabWidget = tab_widget self.code_editor_widget: Optional[QgsCodeEditorWidget] = None - self.path: Optional[str] = None # recent modification time self.lastModified = 0 @@ -293,7 +292,7 @@ def shareOnGist(self, is_public): URL = "https://api.github.com/gists" - path = self.tab_widget.currentWidget().path + path = self.code_editor_widget.filePath() filename = os.path.basename(path) if path else None filename = filename if filename else "pyqgis_snippet.py" @@ -334,8 +333,7 @@ def createTempFile(self): def runScriptCode(self): autoSave = QgsSettings().value("pythonConsole/autoSaveScript", False, type=bool) - tabWidget = self.tab_widget.currentWidget() - filename = tabWidget.path + filename = self.code_editor_widget.filePath() filename_override = None msgEditorBlank = QCoreApplication.translate('PythonConsole', 'Hey, type something to run!') @@ -402,34 +400,33 @@ def syntaxCheck(self): return True def focusInEvent(self, e): - if self.path: - if not QFileInfo(self.path).exists(): + if self.code_editor_widget.filePath(): + if not QFileInfo(self.code_editor_widget.filePath()).exists(): msgText = QCoreApplication.translate('PythonConsole', - 'The file "{0}" has been deleted or is not accessible').format(self.path) + 'The file "{0}" has been deleted or is not accessible').format(self.code_editor_widget.filePath()) self.showMessage(msgText, level=Qgis.MessageLevel.Critical) return - if self.path and self.lastModified != QFileInfo(self.path).lastModified(): + if self.code_editor_widget.filePath() and self.lastModified != QFileInfo(self.code_editor_widget.filePath()).lastModified(): self.beginUndoAction() self.selectAll() self.removeSelectedText() - self.insert(Path(self.path).read_text(encoding='utf-8')) + self.insert(Path(self.code_editor_widget.filePath()).read_text(encoding='utf-8')) self.setModified(False) self.endUndoAction() self.tab_widget.listObject(self.tab_widget.currentWidget()) - self.lastModified = QFileInfo(self.path).lastModified() + self.lastModified = QFileInfo(self.code_editor_widget.filePath()).lastModified() super().focusInEvent(e) def fileReadOnly(self): - tabWidget = self.tab_widget.currentWidget() msgText = QCoreApplication.translate('PythonConsole', - 'The file "{0}" is read only, please save to different file first.').format(tabWidget.path) + 'The file "{0}" is read only, please save to different file first.').format(self.code_editor_widget.filePath()) self.showMessage(msgText) def loadFile(self, filename: str, read_only: bool = False): self.lastModified = QFileInfo(filename).lastModified() - self.path = filename + self.code_editor_widget.setFilePath(filename) self.setText(Path(filename).read_text(encoding='utf-8')) self.setReadOnly(read_only) self.setModified(False) @@ -444,19 +441,20 @@ def save(self, filename: Optional[str] = None): index = self.tab_widget.indexOf(self.editor_tab) if filename: - self.path = filename - if not self.path: + self.code_editor_widget.setFilePath(filename) + if not self.code_editor_widget.filePath(): saveTr = QCoreApplication.translate('PythonConsole', 'Python Console: Save file') folder = QgsSettings().value("pythonConsole/lastDirPath", QDir.homePath()) - self.path, filter = QFileDialog().getSaveFileName(self, - saveTr, - os.path.join(folder, self.tab_widget.tabText(index).replace('*', '') + '.py'), - "Script file (*.py)") + path, filter = QFileDialog().getSaveFileName(self, + saveTr, + os.path.join(folder, self.tab_widget.tabText(index).replace('*', '') + '.py'), + "Script file (*.py)") # If the user didn't select a file, abort the save operation - if not self.path: - self.path = None + if not path: + self.code_editor_widget.setFilePath(None) return + self.code_editor_widget.setFilePath(path) msgText = QCoreApplication.translate('PythonConsole', 'Script was correctly saved.') @@ -464,17 +462,17 @@ def save(self, filename: Optional[str] = None): # Save the new contents # Need to use newline='' to avoid adding extra \r characters on Windows - with open(self.path, 'w', encoding='utf-8', newline='') as f: + with open(self.code_editor_widget.filePath(), 'w', encoding='utf-8', newline='') as f: f.write(self.text()) - self.tab_widget.setTabTitle(index, Path(self.path).name) - self.tab_widget.setTabToolTip(index, self.path) + self.tab_widget.setTabTitle(index, Path(self.code_editor_widget.filePath()).name) + self.tab_widget.setTabToolTip(index, self.code_editor_widget.filePath()) self.setModified(False) self.console_widget.saveFileButton.setEnabled(False) - self.lastModified = QFileInfo(self.path).lastModified() - self.console_widget.updateTabListScript(self.path, action='append') + self.lastModified = QFileInfo(self.code_editor_widget.filePath()).lastModified() + self.console_widget.updateTabListScript(self.code_editor_widget.filePath(), action='append') self.tab_widget.listObject(self.editor_tab) QgsSettings().setValue("pythonConsole/lastDirPath", - Path(self.path).parent.as_posix()) + Path(self.code_editor_widget.filePath()).parent.as_posix()) def event(self, e): """ Used to override the Application shortcuts when the editor has focus """ @@ -564,6 +562,15 @@ def __init__(self, self.tabLayout.setContentsMargins(0, 0, 0, 0) self.tabLayout.addWidget(self._editor_code_widget) + def set_file_path(self, path: str): + self._editor_code_widget.setFilePath(path) + + def file_path(self) -> Optional[str]: + return self._editor_code_widget.filePath() + + def open_in_external_editor(self): + self._editor_code_widget.openInExternalEditor() + def modified(self, modified): self.tab_widget.tabModified(self, modified) @@ -850,14 +857,14 @@ def _removeTab(self, tab, tab2index=False): return if res == QMessageBox.StandardButton.Save: editorTab.save() - if editorTab.path: - self.console_widget.updateTabListScript(editorTab.path, action='remove') + if editorTab.code_editor_widget.filePath(): + self.console_widget.updateTabListScript(editorTab.code_editor_widget.filePath(), action='remove') self.removeTab(tab) if self.count() < 1: self.newTabEditor() else: - if editorTab.path: - self.console_widget.updateTabListScript(editorTab.path, action='remove') + if editorTab.code_editor_widget.filePath(): + self.console_widget.updateTabListScript(editorTab.code_editor_widget.filePath(), action='remove') if self.count() <= 1: self.removeTab(tab) self.newTabEditor() @@ -923,8 +930,8 @@ def listObject(self, tab): else: tabWidget = self.widget(tab) if tabWidget: - if tabWidget.path: - pathFile, file = os.path.split(tabWidget.path) + if tabWidget.file_path(): + pathFile, file = os.path.split(tabWidget.file_path()) module, ext = os.path.splitext(file) found = False if pathFile not in sys.path: @@ -936,7 +943,7 @@ def listObject(self, tab): readModule = pyclbr.readmodule(module) readModuleFunction = pyclbr.readmodule_ex(module) for name, class_data in sorted(list(readModule.items()), key=lambda x: x[1].lineno): - if os.path.normpath(class_data.file) == os.path.normpath(tabWidget.path): + if os.path.normpath(class_data.file) == os.path.normpath(tabWidget.file_path()): superClassName = [] for superClass in class_data.super: if superClass == 'object': @@ -973,7 +980,7 @@ def listObject(self, tab): self.console_widget.listClassMethod.addTopLevelItem(classItem) for func_name, data in sorted(list(readModuleFunction.items()), key=lambda x: x[1].lineno): if isinstance(data, pyclbr.Function) and \ - os.path.normpath(data.file) == os.path.normpath(tabWidget.path): + os.path.normpath(data.file) == os.path.normpath(tabWidget.file_path()): funcItem = QTreeWidgetItem() funcItem.setText(0, func_name + ' ') funcItem.setText(1, str(data.lineno)) @@ -1009,9 +1016,9 @@ def refreshSettingsEditor(self): def changeLastDirPath(self, tab): tabWidget = self.widget(tab) - if tabWidget and tabWidget.path: + if tabWidget and tabWidget.file_path(): QgsSettings().setValue("pythonConsole/lastDirPath", - Path(tabWidget.path).parent.as_posix()) + Path(tabWidget.file_path()).parent.as_posix()) def showMessage(self, text, level=Qgis.MessageLevel.Info, timeout=-1, title=""): currWidget = self.currentWidget() diff --git a/python/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in b/python/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in index 37174ca39cb9..03aee51b2777 100644 --- a/python/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in +++ b/python/gui/auto_generated/codeeditors/qgscodeeditorwidget.sip.in @@ -90,6 +90,15 @@ This method calls :py:func:`QgsCodeEditor.clearWarnings()`, but also removes highlights from the widget scrollbars at the warning locations. .. seealso:: :py:func:`addWarning` +%End + + QString filePath() const; +%Docstring +Returns the widget's associated file path. + +.. seealso:: :py:func:`setFilePath` + +.. seealso:: :py:func:`filePathChanged` %End public slots: @@ -136,6 +145,24 @@ Triggers a find operation, using the default behavior. This will automatically open the search bar and start a find operation using the default behavior, e.g. searching for any selected text in the code editor. +%End + + void setFilePath( const QString &path ); +%Docstring +Sets the widget's associated file ``path``. + +.. seealso:: :py:func:`filePathChanged` + +.. seealso:: :py:func:`filePath` +%End + + bool openInExternalEditor(); +%Docstring +Attempts to opens the script from the editor in an external text editor. + +This requires that the widget has an associated :py:func:`~QgsCodeEditorWidget.filePath` set. + +:return: ``True`` if the file was opened successfully. %End signals: @@ -143,6 +170,15 @@ the default behavior, e.g. searching for any selected text in the code editor. void searchBarToggled( bool visible ); %Docstring Emitted when the visibility of the search bar is changed. +%End + + void filePathChanged( const QString &path ); +%Docstring +Emitted when the widget's associated file path is changed. + +.. seealso:: :py:func:`setFilePath` + +.. seealso:: :py:func:`filePath` %End }; diff --git a/src/gui/codeeditors/qgscodeeditorwidget.cpp b/src/gui/codeeditors/qgscodeeditorwidget.cpp index 442ff3a0f1ce..a8e555eb0b01 100644 --- a/src/gui/codeeditors/qgscodeeditorwidget.cpp +++ b/src/gui/codeeditors/qgscodeeditorwidget.cpp @@ -26,6 +26,10 @@ #include #include #include +#include +#include +#include +#include QgsCodeEditorWidget::QgsCodeEditorWidget( QgsCodeEditor *editor, @@ -337,6 +341,48 @@ void QgsCodeEditorWidget::triggerFind() showSearchBar(); } +void QgsCodeEditorWidget::setFilePath( const QString &path ) +{ + if ( mFilePath == path ) + return; + + mFilePath = path; + emit filePathChanged( mFilePath ); +} + +bool QgsCodeEditorWidget::openInExternalEditor() +{ + if ( mFilePath.isEmpty() ) + return false; + + const QDir dir = QFileInfo( mFilePath ).dir(); + + bool useFallback = true; + + const QString editorCommand = qgetenv( "EDITOR" ); + if ( !editorCommand.isEmpty() ) + { + const QFileInfo fi( editorCommand ); + if ( fi.exists( ) ) + { + const QString command = fi.fileName(); + const bool isTerminalEditor = command.compare( QLatin1String( "nano" ), Qt::CaseInsensitive ) == 0 + || command.contains( QLatin1String( "vim" ), Qt::CaseInsensitive ); + + if ( !isTerminalEditor && QProcess::startDetached( editorCommand, {mFilePath}, dir.absolutePath() ) ) + { + useFallback = false; + } + } + } + + if ( useFallback ) + { + QDesktopServices::openUrl( QUrl::fromLocalFile( mFilePath ) ); + } + return true; +} + bool QgsCodeEditorWidget::findNext() { return findText( true, false ); diff --git a/src/gui/codeeditors/qgscodeeditorwidget.h b/src/gui/codeeditors/qgscodeeditorwidget.h index a38c22849740..27f344a1bdf9 100644 --- a/src/gui/codeeditors/qgscodeeditorwidget.h +++ b/src/gui/codeeditors/qgscodeeditorwidget.h @@ -106,6 +106,14 @@ class GUI_EXPORT QgsCodeEditorWidget : public QgsPanelWidget */ void clearWarnings(); + /** + * Returns the widget's associated file path. + * + * \see setFilePath() + * \see filePathChanged() + */ + QString filePath() const { return mFilePath; } + public slots: /** @@ -148,6 +156,23 @@ class GUI_EXPORT QgsCodeEditorWidget : public QgsPanelWidget */ void triggerFind(); + /** + * Sets the widget's associated file \a path. + * + * \see filePathChanged() + * \see filePath() + */ + void setFilePath( const QString &path ); + + /** + * Attempts to opens the script from the editor in an external text editor. + * + * This requires that the widget has an associated filePath() set. + * + * \returns TRUE if the file was opened successfully. + */ + bool openInExternalEditor(); + signals: /** @@ -155,6 +180,14 @@ class GUI_EXPORT QgsCodeEditorWidget : public QgsPanelWidget */ void searchBarToggled( bool visible ); + /** + * Emitted when the widget's associated file path is changed. + * + * \see setFilePath() + * \see filePath() + */ + void filePathChanged( const QString &path ); + private slots: bool findNext(); @@ -196,6 +229,7 @@ class GUI_EXPORT QgsCodeEditorWidget : public QgsPanelWidget int mBlockSearching = 0; QgsMessageBar *mMessageBar = nullptr; std::unique_ptr< QgsScrollBarHighlightController > mHighlightController; + QString mFilePath; }; #endif // QGSCODEEDITORWIDGET_H