From 1f9de598704f863867b98258521ee09eb526e695 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Tue, 11 Oct 2022 11:03:12 +1000 Subject: [PATCH] Move python console history handling to base QgsCodeEditor class So that other non-python-console code editors can utilise this too --- python/console/console.py | 2 +- python/console/console_sci.py | 321 +++--------------- .../codeeditors/qgscodeeditor.sip.in | 80 +++++ .../codeeditors/qgscodeeditorpython.sip.in | 3 +- src/gui/CMakeLists.txt | 2 + src/gui/codeeditors/qgscodeeditor.cpp | 195 +++++++++++ src/gui/codeeditors/qgscodeeditor.h | 78 +++++ src/gui/codeeditors/qgscodeeditorcss.cpp | 1 - .../codeeditors/qgscodeeditorexpression.cpp | 3 +- .../qgscodeeditorhistorydialog.cpp | 125 +++++++ .../codeeditors/qgscodeeditorhistorydialog.h | 84 +++++ src/gui/codeeditors/qgscodeeditorhtml.cpp | 2 - src/gui/codeeditors/qgscodeeditorjs.cpp | 1 - src/gui/codeeditors/qgscodeeditorjson.cpp | 1 - src/gui/codeeditors/qgscodeeditorpython.cpp | 4 +- src/gui/codeeditors/qgscodeeditorpython.h | 3 +- src/gui/codeeditors/qgscodeeditorsql.cpp | 4 +- .../ui/qgscodeditorhistorydialogbase.ui | 14 +- 18 files changed, 630 insertions(+), 293 deletions(-) create mode 100644 src/gui/codeeditors/qgscodeeditorhistorydialog.cpp create mode 100644 src/gui/codeeditors/qgscodeeditorhistorydialog.h rename python/console/console_history_dlg.ui => src/ui/qgscodeditorhistorydialogbase.ui (90%) diff --git a/python/console/console.py b/python/console/console.py index 94df050e6494..0ee5451f1885 100644 --- a/python/console/console.py +++ b/python/console/console.py @@ -782,7 +782,7 @@ def saveSettingsConsole(self): self.settings.setValue("pythonConsole/splitterObj", self.splitterObj.saveState()) self.settings.setValue("pythonConsole/splitterEditor", self.splitterEditor.saveState()) - self.shell.writeHistoryFile(True) + self.shell.writeHistoryFile() def restoreSettingsConsole(self): storedTabScripts = self.settings.value("pythonConsole/tabScripts", []) diff --git a/python/console/console_sci.py b/python/console/console_sci.py index 77e3a4ce550c..ed4970196f84 100644 --- a/python/console/console_sci.py +++ b/python/console/console_sci.py @@ -19,28 +19,22 @@ Some portions of code were taken from https://code.google.com/p/pydee/ """ -from qgis.PyQt.QtCore import Qt, QByteArray, QCoreApplication, QFile, QSize -from qgis.PyQt.QtWidgets import QDialog, QMenu, QShortcut, QApplication -from qgis.PyQt.QtGui import QKeySequence, QFontMetrics, QStandardItemModel, QStandardItem, QClipboard -from qgis.PyQt.Qsci import QsciScintilla -from qgis.gui import ( - QgsCodeEditorPython, - QgsCodeEditorColorScheme -) - -import sys -import os import code -import codecs +import os import re +import sys import traceback +from qgis.PyQt.Qsci import QsciScintilla +from qgis.PyQt.QtCore import Qt, QCoreApplication +from qgis.PyQt.QtGui import QKeySequence, QFontMetrics, QClipboard +from qgis.PyQt.QtWidgets import QShortcut, QApplication from qgis.core import QgsApplication, QgsSettings, Qgis -from qgis.gui import QgsCodeEditor - -from .ui_console_history_dlg import Ui_HistoryDialogPythonConsole - -_historyFile = os.path.join(QgsApplication.qgisSettingsDirPath(), "console_history.txt") +from qgis.gui import ( + QgsCodeEditorPython, + QgsCodeEditorColorScheme, + QgsCodeEditor +) _init_statements = [ # Python @@ -75,7 +69,7 @@ class ShellScintilla(QgsCodeEditorPython, code.InteractiveInterpreter): def __init__(self, parent=None): - super(QgsCodeEditorPython, self).__init__(parent) + super(QgsCodeEditorPython, self).__init__(parent, [], QgsCodeEditor.Mode.CommandInput) code.InteractiveInterpreter.__init__(self, locals=None) self.parent = parent @@ -98,27 +92,13 @@ def __init__(self, parent=None): except ModuleNotFoundError: pass - self.history = [] - self.softHistory = [''] - self.softHistoryIndex = 0 - # Read history command file - self.readHistoryFile() + self.setHistoryFilePath( + os.path.join(QgsApplication.qgisSettingsDirPath(), "console_history.txt")) - self.historyDlg = HistoryDialog(self) + self.historyDlg = None # HistoryDialog(self) self.refreshSettingsShell() - # Don't want to see the horizontal scrollbar at all - # Use raw message to Scintilla here (all messages are documented - # here: http://www.scintilla.org/ScintillaDoc.html) - self.SendScintilla(QsciScintilla.SCI_SETHSCROLLBAR, 0) - - # not too small - # self.setMinimumSize(500, 300) - - self.setWrapMode(QsciScintilla.WrapCharacter) - self.SendScintilla(QsciScintilla.SCI_EMPTYUNDOBUFFER) - # Disable command key ctrl, shift = self.SCMOD_CTRL << 16, self.SCMOD_SHIFT << 16 self.SendScintilla(QsciScintilla.SCI_CLEARCMDKEY, ord('L') + ctrl) @@ -136,6 +116,9 @@ def __init__(self, parent=None): self.newShortcutCAS.activated.connect(self.autoComplete) self.newShortcutCSS.activated.connect(self.showHistory) + self.sessionHistoryCleared.connect(self.on_session_history_cleared) + self.persistentHistoryCleared.connect(self.on_persistent_history_cleared) + def initializeLexer(self): super().initializeLexer() self.setCaretLineVisible(False) @@ -161,39 +144,6 @@ def refreshSettingsShell(self): # Sets minimum height for input area based of font metric self._setMinimumHeight() - def showHistory(self): - if not self.historyDlg.isVisible(): - self.historyDlg.show() - self.historyDlg._reloadHistory() - self.historyDlg.activateWindow() - - def commandConsole(self, commands): - if not self.isCursorOnLastLine(): - self.moveCursorToEnd() - for cmd in commands: - self.setText(cmd) - self.entered() - self.moveCursorToEnd() - self.setFocus() - - def getText(self): - """ Get the text as a unicode string. """ - value = self.getBytes().decode('utf-8') - # print (value) printing can give an error because the console font - # may not have all unicode characters - return value - - def getBytes(self): - """ Get the text as bytes (utf-8 encoded). This is how - the data is stored internally. """ - len = self.SendScintilla(self.SCI_GETLENGTH) + 1 - bb = QByteArray(len, '0') - self.SendScintilla(self.SCI_GETTEXT, len, bb) - return bytes(bb)[:-1] - - def getTextLength(self): - return self.SendScintilla(QsciScintilla.SCI_GETLENGTH) - def moveCursorToStart(self): super().moveCursorToStart() self.displayPrompt(self.continuationLine) @@ -202,99 +152,19 @@ def moveCursorToEnd(self): super().moveCursorToEnd() self.displayPrompt(self.continuationLine) - def new_prompt(self, prompt): - """ - Print a new prompt and save its (line, index) position - """ - self.write(prompt, prompt=True) - # now we update our cursor giving end of prompt - line, index = self.getCursorPosition() - self.ensureCursorVisible() - self.ensureLineVisible(line) - def displayPrompt(self, more=False): - self.SendScintilla(QsciScintilla.SCI_MARGINSETTEXT, 0, str.encode("..." if more else ">>>")) - - def syncSoftHistory(self): - self.softHistory = self.history[:] - self.softHistory.append('') - self.softHistoryIndex = len(self.softHistory) - 1 - - def updateSoftHistory(self): - self.softHistory[self.softHistoryIndex] = self.text() - - def updateHistory(self, command, skipSoftHistory=False): - if isinstance(command, list): - for line in command: - self.history.append(line) - elif not command == "": - if len(self.history) <= 0 or \ - command != self.history[-1]: - self.history.append(command) - if not skipSoftHistory: - self.syncSoftHistory() - - def writeHistoryFile(self, fromCloseConsole=False): - ok = False - try: - wH = codecs.open(_historyFile, 'w', encoding='utf-8') - for s in self.history: - wH.write(s + '\n') - ok = True - except: - raise - wH.close() - if ok and not fromCloseConsole: - msgText = QCoreApplication.translate('PythonConsole', - 'History saved successfully.') - self.parent.callWidgetMessageBar(msgText) - - def readHistoryFile(self): - fileExist = QFile.exists(_historyFile) - if fileExist: - with codecs.open(_historyFile, 'r', encoding='utf-8') as rH: - for line in rH: - if line != "\n": - l = line.rstrip('\n') - self.updateHistory(l, True) - self.syncSoftHistory() - else: - return + self.SendScintilla(QsciScintilla.SCI_MARGINSETTEXT, 0, + str.encode("..." if more else ">>>")) - def clearHistory(self, clearSession=False): - if clearSession: - self.history = [] - self.readHistoryFile() - self.syncSoftHistory() - msgText = QCoreApplication.translate('PythonConsole', - 'Session history cleared successfully.') - self.parent.callWidgetMessageBar(msgText) - else: - self.history = [] - if QFile.exists(_historyFile): - with open(_historyFile, 'w', encoding='utf-8') as h: - h.truncate() - - msgText = QCoreApplication.translate('PythonConsole', - 'History cleared successfully.') - self.parent.callWidgetMessageBar(msgText) - - def clearHistorySession(self): - self.clearHistory(True) - - def showPrevious(self): - if self.softHistoryIndex < len(self.softHistory) - 1 and self.softHistory: - self.softHistoryIndex += 1 - self.setText(self.softHistory[self.softHistoryIndex]) - self.moveCursorToEnd() - # self.SendScintilla(QsciScintilla.SCI_DELETEBACK) + def on_session_history_cleared(self): + msgText = QCoreApplication.translate('PythonConsole', + 'Session history cleared successfully.') + self.parent.callWidgetMessageBar(msgText) - def showNext(self): - if self.softHistoryIndex > 0 and self.softHistory: - self.softHistoryIndex -= 1 - self.setText(self.softHistory[self.softHistoryIndex]) - self.moveCursorToEnd() - # self.SendScintilla(QsciScintilla.SCI_DELETEBACK) + def on_persistent_history_cleared(self): + msgText = QCoreApplication.translate('PythonConsole', + 'History cleared successfully.') + self.parent.callWidgetMessageBar(msgText) def keyPressEvent(self, e): # update the live history @@ -350,16 +220,18 @@ def keyPressEvent(self, e): e.accept() elif e.key() == Qt.Key_Down and not self.isListActive(): - self.showPrevious() + self.showPreviousCommand() elif e.key() == Qt.Key_Up and not self.isListActive(): - self.showNext() + self.showNextCommand() # TODO: press event for auto-completion file directory else: t = e.text() - self.autoCloseBracket = self.settings.value("pythonConsole/autoCloseBracket", False, type=bool) - self.autoImport = self.settings.value("pythonConsole/autoInsertionImport", True, type=bool) + self.autoCloseBracket = self.settings.value("pythonConsole/autoCloseBracket", False, + type=bool) + self.autoImport = self.settings.value("pythonConsole/autoInsertionImport", True, + type=bool) # Close bracket automatically if t in self.opening and self.autoCloseBracket: i = self.opening.index(t) @@ -392,42 +264,13 @@ def keyPressEvent(self, e): self.displayPrompt(self.continuationLine) - def contextMenuEvent(self, e): - menu = QMenu(self) - subMenu = QMenu(menu) - titleHistoryMenu = QCoreApplication.translate("PythonConsole", "Command History") - subMenu.setTitle(titleHistoryMenu) - subMenu.addAction( - QCoreApplication.translate("PythonConsole", "Show"), - self.showHistory, 'Ctrl+Shift+SPACE') - subMenu.addAction( - QCoreApplication.translate("PythonConsole", "Clear File"), - self.clearHistory) - subMenu.addAction( - QCoreApplication.translate("PythonConsole", "Clear Session"), - self.clearHistorySession) - menu.addMenu(subMenu) - menu.addSeparator() - copyAction = menu.addAction( - QgsApplication.getThemeIcon("mActionEditCopy.svg"), - QCoreApplication.translate("PythonConsole", "Copy"), - self.copy, QKeySequence.Copy) - pasteAction = menu.addAction( - QgsApplication.getThemeIcon("mActionEditPaste.svg"), - QCoreApplication.translate("PythonConsole", "Paste"), - self.paste, QKeySequence.Paste) - pyQGISHelpAction = menu.addAction(QgsApplication.getThemeIcon("console/iconHelpConsole.svg"), - QCoreApplication.translate("PythonConsole", "Search Selected in PyQGIS docs"), - self.searchSelectedTextInPyQGISDocs) - copyAction.setEnabled(False) - pasteAction.setEnabled(False) - pyQGISHelpAction.setEnabled(False) - if self.hasSelectedText(): - copyAction.setEnabled(True) - pyQGISHelpAction.setEnabled(True) - if QApplication.clipboard().text(): - pasteAction.setEnabled(True) - menu.exec_(self.mapToGlobal(e.pos())) + def populateContextMenu(self, menu): + pyQGISHelpAction = menu.addAction( + QgsApplication.getThemeIcon("console/iconHelpConsole.svg"), + QCoreApplication.translate("PythonConsole", "Search Selected in PyQGIS docs"), + self.searchSelectedTextInPyQGISDocs + ) + pyQGISHelpAction.setEnabled(self.hasSelectedText()) def mousePressEvent(self, e): """ @@ -504,23 +347,25 @@ def entered(self): def runCommand(self, cmd): self.writeCMD(cmd) import webbrowser - self.updateHistory(cmd) - version = 'master' if 'master' in Qgis.QGIS_VERSION.lower() else re.findall(r'^\d.[0-9]*', Qgis.QGIS_VERSION)[0] + self.updateHistory([cmd]) + version = 'master' if 'master' in Qgis.QGIS_VERSION.lower() else \ + re.findall(r'^\d.[0-9]*', Qgis.QGIS_VERSION)[0] if cmd in ('_pyqgis', '_api', '_cookbook'): if cmd == '_pyqgis': webbrowser.open("https://qgis.org/pyqgis/{}".format(version)) elif cmd == '_api': - webbrowser.open("https://qgis.org/api/{}".format('' if version == 'master' else version)) + webbrowser.open( + "https://qgis.org/api/{}".format('' if version == 'master' else version)) elif cmd == '_cookbook': - webbrowser.open("https://docs.qgis.org/{}/en/docs/pyqgis_developer_cookbook/".format( - 'testing' if version == 'master' else version)) + webbrowser.open( + "https://docs.qgis.org/{}/en/docs/pyqgis_developer_cookbook/".format( + 'testing' if version == 'master' else version)) else: self.buffer.append(cmd) src = "\n".join(self.buffer) more = self.runsource(src) - self.continuationLine = True + self.continuationLine = more if not more: - self.continuationLine = False self.buffer = [] # prevents to commands with more lines to break the console @@ -553,69 +398,3 @@ def excepthook(etype, value, tb): return super(ShellScintilla, self).runsource(source, filename, symbol) finally: sys.excepthook = hook - - -class HistoryDialog(QDialog, Ui_HistoryDialogPythonConsole): - - def __init__(self, parent): - QDialog.__init__(self, parent) - self.setupUi(self) - self.parent = parent - self.setWindowTitle(QCoreApplication.translate("PythonConsole", - "Python Console - Command History")) - self.listView.setToolTip(QCoreApplication.translate("PythonConsole", - "Double-click on item to execute")) - - self.listView.setFont(QgsCodeEditorPython.getMonospaceFont()) - - self.model = QStandardItemModel(self.listView) - - self._reloadHistory() - - self.deleteScut = QShortcut(QKeySequence(Qt.Key_Delete), self) - self.deleteScut.activated.connect(self._deleteItem) - self.listView.doubleClicked.connect(self._runHistory) - self.reloadHistory.clicked.connect(self._reloadHistory) - self.saveHistory.clicked.connect(self._saveHistory) - self.runHistoryButton.clicked.connect(self._executeSelectedHistory) - - def _executeSelectedHistory(self): - items = self.listView.selectionModel().selectedIndexes() - items.sort() - for item in items: - self.parent.runCommand(item.data(Qt.DisplayRole)) - - def _runHistory(self, item): - cmd = item.data(Qt.DisplayRole) - self.parent.runCommand(cmd) - - def _saveHistory(self): - self.parent.writeHistoryFile(True) - - def _reloadHistory(self): - self.model.clear() - item = None - for i in self.parent.history: - item = QStandardItem(i) - if sys.platform.startswith('win'): - item.setSizeHint(QSize(18, 18)) - self.model.appendRow(item) - - self.listView.setModel(self.model) - self.listView.scrollToBottom() - if item: - self.listView.setCurrentIndex(self.model.indexFromItem(item)) - - def _deleteItem(self): - itemsSelected = self.listView.selectionModel().selectedIndexes() - if itemsSelected: - item = itemsSelected[0].row() - # Remove item from the command history (just for the current session) - self.parent.history.pop(item) - self.parent.softHistory.pop(item) - if item < self.parent.softHistoryIndex: - self.parent.softHistoryIndex -= 1 - self.parent.setText(self.parent.softHistory[self.parent.softHistoryIndex]) - self.parent.moveCursorToEnd() - # Remove row from the command history dialog - self.model.removeRow(item) diff --git a/python/gui/auto_generated/codeeditors/qgscodeeditor.sip.in b/python/gui/auto_generated/codeeditors/qgscodeeditor.sip.in index 87d31c72aaa1..6b406a7cb94e 100644 --- a/python/gui/auto_generated/codeeditors/qgscodeeditor.sip.in +++ b/python/gui/auto_generated/codeeditors/qgscodeeditor.sip.in @@ -227,8 +227,32 @@ Returns ``True`` if the cursor is on the last line of the document. .. versionadded:: 3.28 %End + void setHistoryFilePath( const QString &path ); +%Docstring +Sets the file path to use for recording and retrieving previously +executed commands. + +.. note:: + + Applies to code editors in the QgsCodeEditor.Mode.CommandInput mode only. + +.. versionadded:: 3.30 +%End + + + QStringList history() const; + public slots: + virtual void runCommand( const QString &command ); +%Docstring +Runs a command in the editor. + +The base class method does nothing. + +.. versionadded:: 3.30 +%End + virtual void moveCursorToStart(); %Docstring Moves the cursor to the start of the document and scrolls to ensure @@ -245,6 +269,53 @@ it is visible. .. versionadded:: 3.28 %End + void showPreviousCommand(); +%Docstring +Shows the previous command from the session in the editor. + +.. note:: + + Applies to code editors in the QgsCodeEditor.Mode.CommandInput mode only. + +.. versionadded:: 3.30 +%End + + void showNextCommand(); +%Docstring +Shows the next command from the session in the editor. + +.. note:: + + Applies to code editors in the QgsCodeEditor.Mode.CommandInput mode only. + +.. versionadded:: 3.30 +%End + + void showHistory(); + + void removeHistoryCommand( int index ); + + void clearSessionHistory(); +%Docstring +Clears the history of commands run in the current session. + +.. note:: + + Applies to code editors in the QgsCodeEditor.Mode.CommandInput mode only. + +.. versionadded:: 3.30 +%End + + + void clearPersistentHistory(); + + bool writeHistoryFile(); + + signals: + + void sessionHistoryCleared(); + void persistentHistoryCleared(); + protected: bool isFixedPitch( const QFont &font ); @@ -253,6 +324,8 @@ it is visible. virtual void keyPressEvent( QKeyEvent *event ); + virtual void contextMenuEvent( QContextMenuEvent *event ); + virtual void initializeLexer(); %Docstring @@ -284,6 +357,13 @@ Performs tasks which must be run after a lexer has been set for the widget. .. versionadded:: 3.16 %End + + void syncSoftHistory(); + void updateSoftHistory(); + void updateHistory( const QStringList &commands, bool skipSoftHistory = false ); + + virtual void populateContextMenu( QMenu *menu ); + }; QFlags operator|(QgsCodeEditor::Flag f1, QFlags f2); diff --git a/python/gui/auto_generated/codeeditors/qgscodeeditorpython.sip.in b/python/gui/auto_generated/codeeditors/qgscodeeditorpython.sip.in index 0a3b8e6f8381..1bda741d097f 100644 --- a/python/gui/auto_generated/codeeditors/qgscodeeditorpython.sip.in +++ b/python/gui/auto_generated/codeeditors/qgscodeeditorpython.sip.in @@ -28,7 +28,8 @@ code autocompletion. %End public: - QgsCodeEditorPython( QWidget *parent /TransferThis/ = 0, const QList &filenames = QList() ); + QgsCodeEditorPython( QWidget *parent /TransferThis/ = 0, const QList &filenames = QList(), + QgsCodeEditor::Mode mode = QgsCodeEditor::Mode::ScriptEditor ); %Docstring Construct a new Python editor. diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 5e4bf356f01d..a7d05b53c888 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -128,6 +128,7 @@ set(QGIS_GUI_SRCS codeeditors/qgscodeeditorcolorscheme.cpp codeeditors/qgscodeeditorcolorschemeregistry.cpp codeeditors/qgscodeeditorcss.cpp + codeeditors/qgscodeeditorhistorydialog.cpp codeeditors/qgscodeeditorhtml.cpp codeeditors/qgscodeeditorjs.cpp codeeditors/qgscodeeditorjson.cpp @@ -998,6 +999,7 @@ set(QGIS_GUI_HDRS codeeditors/qgscodeeditorcolorschemeregistry.h codeeditors/qgscodeeditorcss.h codeeditors/qgscodeeditorexpression.h + codeeditors/qgscodeeditorhistorydialog.h codeeditors/qgscodeeditorhtml.h codeeditors/qgscodeeditorjs.h codeeditors/qgscodeeditorjson.h diff --git a/src/gui/codeeditors/qgscodeeditor.cpp b/src/gui/codeeditors/qgscodeeditor.cpp index 698dad4c43a9..5e2b6a0088db 100644 --- a/src/gui/codeeditors/qgscodeeditor.cpp +++ b/src/gui/codeeditors/qgscodeeditor.cpp @@ -20,6 +20,7 @@ #include "qgssymbollayerutils.h" #include "qgsgui.h" #include "qgscodeeditorcolorschemeregistry.h" +#include "qgscodeeditorhistorydialog.h" #include #include @@ -28,6 +29,8 @@ #include #include #include +#include +#include QMap< QgsCodeEditorColorScheme::ColorRole, QString > QgsCodeEditor::sColorRoleToSettingsKey { @@ -88,6 +91,8 @@ QgsCodeEditor::QgsCodeEditor( QWidget *parent, const QString &title, bool foldin if ( folding ) mFlags |= QgsCodeEditor::Flag::CodeFolding; + mSoftHistory.append( QString() ); + setSciWidget(); setHorizontalScrollBarPolicy( Qt::ScrollBarAsNeeded ); @@ -176,6 +181,34 @@ void QgsCodeEditor::keyPressEvent( QKeyEvent *event ) } } +void QgsCodeEditor::contextMenuEvent( QContextMenuEvent *event ) +{ + if ( mMode != QgsCodeEditor::Mode::CommandInput ) + { + QsciScintilla::contextMenuEvent( event ); + return; + } + + QMenu *menu = new QMenu( this ); + QMenu *historySubMenu = new QMenu( tr( "Command History" ), menu ); + + historySubMenu->addAction( tr( "Show" ), this, &QgsCodeEditor::showHistory, QStringLiteral( "Ctrl+Shift+SPACE" ) ); + historySubMenu->addAction( tr( "Clear File" ), this, &QgsCodeEditor::clearPersistentHistory ); + historySubMenu->addAction( tr( "Clear Session" ), this, &QgsCodeEditor::clearSessionHistory ); + + menu->addMenu( historySubMenu ); + menu->addSeparator(); + + QAction *copyAction = menu->addAction( QgsApplication::getThemeIcon( "mActionEditCopy.svg" ), tr( "Copy" ), this, &QgsCodeEditor::copy, QKeySequence::Copy ); + QAction *pasteAction = menu->addAction( QgsApplication::getThemeIcon( "mActionEditPaste.svg" ), tr( "Paste" ), this, &QgsCodeEditor::paste, QKeySequence::Paste ); + copyAction->setEnabled( hasSelectedText() ); + pasteAction->setEnabled( !QApplication::clipboard()->text().isEmpty() ); + + populateContextMenu( menu ); + + menu->exec( mapToGlobal( event->pos() ) ); +} + void QgsCodeEditor::initializeLexer() { @@ -406,6 +439,162 @@ void QgsCodeEditor::updateFolding() } } +bool QgsCodeEditor::readHistoryFile() +{ + if ( mHistoryFilePath.isEmpty() || !QFile::exists( mHistoryFilePath ) ) + return false; + + QFile file( mHistoryFilePath ); + if ( file.open( QIODevice::ReadOnly ) ) + { + QTextStream stream( &file ); +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + // Always use UTF-8 + stream.setCodec( "UTF-8" ); +#endif + QString line; + while ( !stream.atEnd() ) + { + line = stream.readLine(); // line of text excluding '\n' + mHistory.append( line ); + } + syncSoftHistory(); + return true; + } + + return false; +} + +void QgsCodeEditor::syncSoftHistory() +{ + mSoftHistory = mHistory; + mSoftHistory.append( QString() ); + mSoftHistoryIndex = mSoftHistory.length() - 1; +} + +void QgsCodeEditor::updateSoftHistory() +{ + mSoftHistory[mSoftHistoryIndex] = text(); +} + +void QgsCodeEditor::updateHistory( const QStringList &commands, bool skipSoftHistory ) +{ + if ( commands.size() > 1 ) + { + mHistory.append( commands ); + } + else if ( !commands.value( 0 ).isEmpty() ) + { + const QString command = commands.value( 0 ); + if ( mHistory.empty() || command != mHistory.constLast() ) + mHistory.append( command ); + } + + if ( !skipSoftHistory ) + syncSoftHistory(); +} + +void QgsCodeEditor::populateContextMenu( QMenu * ) +{ + +} + +QStringList QgsCodeEditor::history() const +{ + return mHistory; +} + +void QgsCodeEditor::runCommand( const QString & ) +{ + +} + +void QgsCodeEditor::clearSessionHistory() +{ + mHistory.clear(); + readHistoryFile(); + syncSoftHistory(); + + emit sessionHistoryCleared(); +} + +void QgsCodeEditor::clearPersistentHistory() +{ + mHistory.clear(); + + if ( !mHistoryFilePath.isEmpty() && QFile::exists( mHistoryFilePath ) ) + { + QFile file( mHistoryFilePath ); + file.open( QFile::WriteOnly | QFile::Truncate ); + } + + emit persistentHistoryCleared(); +} + +bool QgsCodeEditor::writeHistoryFile() +{ + if ( mHistoryFilePath.isEmpty() ) + return false; + + QFile f( mHistoryFilePath ); + if ( !f.open( QFile::WriteOnly | QIODevice::Truncate ) ) + { + return false; + } + + QTextStream ts( &f ); +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + ts.setCodec( "UTF-8" ); +#endif + for ( const QString &command : std::as_const( mHistory ) ) + { + ts << command + '\n'; + } + return true; +} + +void QgsCodeEditor::showPreviousCommand() +{ + if ( mSoftHistoryIndex < mSoftHistory.length() - 1 && !mSoftHistory.isEmpty() ) + { + mSoftHistoryIndex += 1; + setText( mSoftHistory[mSoftHistoryIndex] ); + moveCursorToEnd(); + } +} + +void QgsCodeEditor::showNextCommand() +{ + if ( mSoftHistoryIndex > 0 && !mSoftHistory.empty() ) + { + mSoftHistoryIndex -= 1; + setText( mSoftHistory[mSoftHistoryIndex] ); + moveCursorToEnd(); + } +} + +void QgsCodeEditor::showHistory() +{ + QgsCodeEditorHistoryDialog *dialog = new QgsCodeEditorHistoryDialog( this, this ); + dialog->setAttribute( Qt::WA_DeleteOnClose ); + + dialog->show(); + dialog->activateWindow(); +} + +void QgsCodeEditor::removeHistoryCommand( int index ) +{ + // remove item from the command history (just for the current session) + mHistory.removeAt( index ); + mSoftHistory.removeAt( index ); + if ( index < mSoftHistoryIndex ) + { + mSoftHistoryIndex -= 1; + if ( mSoftHistoryIndex < 0 ) + mSoftHistoryIndex = mSoftHistory.length() - 1; + } +} + void QgsCodeEditor::insertText( const QString &text ) { // Insert the text or replace selected text @@ -604,6 +793,12 @@ bool QgsCodeEditor::isCursorOnLastLine() const return line == lines() - 1; } +void QgsCodeEditor::setHistoryFilePath( const QString &path ) +{ + mHistoryFilePath = path; + readHistoryFile(); +} + void QgsCodeEditor::moveCursorToStart() { setCursorPosition( 0, 0 ); diff --git a/src/gui/codeeditors/qgscodeeditor.h b/src/gui/codeeditors/qgscodeeditor.h index 6761f3f275d9..965c81e0581d 100644 --- a/src/gui/codeeditors/qgscodeeditor.h +++ b/src/gui/codeeditors/qgscodeeditor.h @@ -259,8 +259,30 @@ class GUI_EXPORT QgsCodeEditor : public QsciScintilla */ bool isCursorOnLastLine() const; + /** + * Sets the file path to use for recording and retrieving previously + * executed commands. + * + * \note Applies to code editors in the QgsCodeEditor::Mode::CommandInput mode only. + * + * \since QGIS 3.30 + */ + void setHistoryFilePath( const QString &path ); + + + QStringList history() const; + public slots: + /** + * Runs a command in the editor. + * + * The base class method does nothing. + + * \since QGIS 3.30 + */ + virtual void runCommand( const QString &command ); + /** * Moves the cursor to the start of the document and scrolls to ensure * it is visible. @@ -277,12 +299,54 @@ class GUI_EXPORT QgsCodeEditor : public QsciScintilla */ virtual void moveCursorToEnd(); + /** + * Shows the previous command from the session in the editor. + * + * \note Applies to code editors in the QgsCodeEditor::Mode::CommandInput mode only. + * + * \since QGIS 3.30 + */ + void showPreviousCommand(); + + /** + * Shows the next command from the session in the editor. + * + * \note Applies to code editors in the QgsCodeEditor::Mode::CommandInput mode only. + * + * \since QGIS 3.30 + */ + void showNextCommand(); + + void showHistory(); + + void removeHistoryCommand( int index ); + + /** + * Clears the history of commands run in the current session. + * + * \note Applies to code editors in the QgsCodeEditor::Mode::CommandInput mode only. + * + * \since QGIS 3.30 + */ + void clearSessionHistory(); + + + void clearPersistentHistory(); + + bool writeHistoryFile(); + + signals: + + void sessionHistoryCleared(); + void persistentHistoryCleared(); + protected: bool isFixedPitch( const QFont &font ); void focusOutEvent( QFocusEvent *event ) override; void keyPressEvent( QKeyEvent *event ) override; + void contextMenuEvent( QContextMenuEvent *event ) override; /** * Called when the dialect specific code lexer needs to be initialized (or reinitialized). @@ -314,10 +378,18 @@ class GUI_EXPORT QgsCodeEditor : public QsciScintilla */ void runPostLexerConfigurationTasks(); + + void syncSoftHistory(); + void updateSoftHistory(); + void updateHistory( const QStringList &commands, bool skipSoftHistory = false ); + + virtual void populateContextMenu( QMenu *menu ); + private: void setSciWidget(); void updateFolding(); + bool readHistoryFile(); QString mWidgetTitle; bool mMargin = false; @@ -334,6 +406,12 @@ class GUI_EXPORT QgsCodeEditor : public QsciScintilla QVector< int > mWarningLines; + // for use in command input mode + QStringList mHistory; + QStringList mSoftHistory; + int mSoftHistoryIndex = 0; + QString mHistoryFilePath; + static QMap< QgsCodeEditorColorScheme::ColorRole, QString > sColorRoleToSettingsKey; static constexpr int MARKER_NUMBER = 6; diff --git a/src/gui/codeeditors/qgscodeeditorcss.cpp b/src/gui/codeeditors/qgscodeeditorcss.cpp index 0f5a06ce44a8..889478250b6f 100644 --- a/src/gui/codeeditors/qgscodeeditorcss.cpp +++ b/src/gui/codeeditors/qgscodeeditorcss.cpp @@ -13,7 +13,6 @@ * * ***************************************************************************/ -#include "qgsapplication.h" #include "qgscodeeditorcss.h" #include diff --git a/src/gui/codeeditors/qgscodeeditorexpression.cpp b/src/gui/codeeditors/qgscodeeditorexpression.cpp index 205fdfb2272d..31262fe807f6 100644 --- a/src/gui/codeeditors/qgscodeeditorexpression.cpp +++ b/src/gui/codeeditors/qgscodeeditorexpression.cpp @@ -13,9 +13,8 @@ * * ***************************************************************************/ -#include "qgsapplication.h" #include "qgscodeeditorexpression.h" -#include "qgssymbollayerutils.h" +#include "qgsexpression.h" #include #include diff --git a/src/gui/codeeditors/qgscodeeditorhistorydialog.cpp b/src/gui/codeeditors/qgscodeeditorhistorydialog.cpp new file mode 100644 index 000000000000..fb4b148a57ad --- /dev/null +++ b/src/gui/codeeditors/qgscodeeditorhistorydialog.cpp @@ -0,0 +1,125 @@ +/*************************************************************************** + qgscodeeditorhistorydialog.cpp + ---------------------- + begin : October 2022 + copyright : (C) 2022 by Nyall Dawson + email : nyall dot dawson at gmail dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "qgscodeeditorhistorydialog.h" +#include "qgscodeeditor.h" +#include +#include + +QgsCodeEditorHistoryDialog::QgsCodeEditorHistoryDialog( QgsCodeEditor *editor, QWidget *parent ) + : QDialog( parent ) + , mEditor( editor ) +{ + setupUi( this ); + + if ( mEditor ) + { + setWindowTitle( tr( "%1 Console - Command History" ).arg( QgsCodeEditor::languageToString( mEditor->language() ) ) ); + } + + listView->setToolTip( tr( "Double-click on item to execute" ) ); + + mModel = new CodeHistoryModel( listView ); + listView->setModel( mModel ); + + reloadHistory(); + + QShortcut *deleteShortcut = new QShortcut( QKeySequence( Qt::Key_Delete ), this ); + connect( deleteShortcut, &QShortcut::activated, this, &QgsCodeEditorHistoryDialog::deleteItem ); + connect( listView, &QListView::doubleClicked, this, &QgsCodeEditorHistoryDialog::runCommand ); + connect( mButtonReloadHistory, &QPushButton::clicked, this, & QgsCodeEditorHistoryDialog::reloadHistory ); + connect( mButtonSaveHistory, &QPushButton::clicked, this, & QgsCodeEditorHistoryDialog::saveHistory ); + connect( mButtonRunHistory, &QPushButton::clicked, this, &QgsCodeEditorHistoryDialog::executeSelectedHistory ); +} + +void QgsCodeEditorHistoryDialog::executeSelectedHistory() +{ + if ( !mEditor ) + return; + + QModelIndexList selection = listView->selectionModel()->selectedIndexes(); + std::sort( selection.begin(), selection.end() ); + for ( const QModelIndex &index : std::as_const( selection ) ) + { + mEditor->runCommand( index.data( Qt::DisplayRole ).toString() ); + } +} + +void QgsCodeEditorHistoryDialog::runCommand( const QModelIndex &index ) +{ + if ( !mEditor ) + return; + + mEditor->runCommand( index.data( Qt::DisplayRole ).toString() ); +} + +void QgsCodeEditorHistoryDialog::saveHistory() +{ + if ( !mEditor ) + return; + + mEditor->writeHistoryFile(); +} + +void QgsCodeEditorHistoryDialog::reloadHistory() +{ + if ( mEditor ) + { + mModel->setStringList( mEditor->history() ); + } + + listView->scrollToBottom(); + listView->setCurrentIndex( mModel->index( mModel->rowCount() - 1, 0 ) ); +} + +void QgsCodeEditorHistoryDialog::deleteItem() +{ + const QModelIndexList selection = listView->selectionModel()->selectedRows(); + if ( selection.empty() ) + return; + + QList< int > selectedRows; + selectedRows.reserve( selection.size() ); + for ( const QModelIndex &index : selection ) + selectedRows << index.row(); + std::sort( selectedRows.begin(), selectedRows.end(), std::greater< int >() ); + + for ( int row : std::as_const( selectedRows ) ) + { + if ( mEditor ) + mEditor->removeHistoryCommand( row ); + + // Remove row from the command history dialog + mModel->removeRow( row ); + } +} + +///@cond PRIVATE +CodeHistoryModel::CodeHistoryModel( QObject *parent ) + : QStringListModel( parent ) +{ + mFont = QgsCodeEditor::getMonospaceFont(); +} + +QVariant CodeHistoryModel::data( const QModelIndex &index, int role ) const +{ + if ( role == Qt::FontRole ) + { + return mFont; + } + + return QStringListModel::data( index, role ); +} +///@endcond diff --git a/src/gui/codeeditors/qgscodeeditorhistorydialog.h b/src/gui/codeeditors/qgscodeeditorhistorydialog.h new file mode 100644 index 000000000000..b6dc1bf9f485 --- /dev/null +++ b/src/gui/codeeditors/qgscodeeditorhistorydialog.h @@ -0,0 +1,84 @@ +/*************************************************************************** + qgscodeeditorhistorydialog.h + ---------------------- + begin : October 2022 + copyright : (C) 2022 by Nyall Dawson + email : nyall dot dawson at gmail dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef QGSCODEEDITORHISTORYDIALOG_H +#define QGSCODEEDITORHISTORYDIALOG_H + +#include "ui_qgscodeditorhistorydialogbase.h" +#include +#include +#include +#include "qgis_gui.h" +#include "qgis_sip.h" + +#define SIP_NO_FILE + +class QgsCodeEditor; + + +///@cond PRIVATE + +class CodeHistoryModel : public QStringListModel +{ + Q_OBJECT + + public: + CodeHistoryModel( QObject *parent ); + QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const override; + + private: + + QFont mFont; +}; + +///@endcond + + +/** + * \ingroup gui + * \class QgsCodeEditorHistoryDialog + * \brief A dialog for displaying and managing command history for a QgsCodeEditor widget. + * \note Not available in Python bindings + * \since QGIS 3.30 + */ +class GUI_EXPORT QgsCodeEditorHistoryDialog : public QDialog, private Ui::QgsCodeEditorHistoryDialogBase +{ + Q_OBJECT + + public: + + /** + * Constructor for QgsCodeEditorHistoryDialog. + * \param editor associated code editor widget + * \param parent parent widget + */ + QgsCodeEditorHistoryDialog( QgsCodeEditor *editor, QWidget *parent SIP_TRANSFERTHIS = nullptr ); + + private slots: + + void executeSelectedHistory(); + void runCommand( const QModelIndex &index ); + void saveHistory(); + void reloadHistory(); + void deleteItem(); + + private: + + QPointer< QgsCodeEditor > mEditor; + CodeHistoryModel *mModel = nullptr; + +}; + +#endif // QGSCODEEDITORHISTORYDIALOG_H diff --git a/src/gui/codeeditors/qgscodeeditorhtml.cpp b/src/gui/codeeditors/qgscodeeditorhtml.cpp index f175abbbf9b5..2697296a83bb 100644 --- a/src/gui/codeeditors/qgscodeeditorhtml.cpp +++ b/src/gui/codeeditors/qgscodeeditorhtml.cpp @@ -13,9 +13,7 @@ * * ***************************************************************************/ -#include "qgsapplication.h" #include "qgscodeeditorhtml.h" -#include "qgssymbollayerutils.h" #include #include diff --git a/src/gui/codeeditors/qgscodeeditorjs.cpp b/src/gui/codeeditors/qgscodeeditorjs.cpp index e21cf5b84d56..7c40be898ef7 100644 --- a/src/gui/codeeditors/qgscodeeditorjs.cpp +++ b/src/gui/codeeditors/qgscodeeditorjs.cpp @@ -13,7 +13,6 @@ * * ***************************************************************************/ -#include "qgsapplication.h" #include "qgscodeeditorjs.h" #include diff --git a/src/gui/codeeditors/qgscodeeditorjson.cpp b/src/gui/codeeditors/qgscodeeditorjson.cpp index e1fdb726e2fe..df5a381beed3 100644 --- a/src/gui/codeeditors/qgscodeeditorjson.cpp +++ b/src/gui/codeeditors/qgscodeeditorjson.cpp @@ -13,7 +13,6 @@ * * ***************************************************************************/ -#include "qgsapplication.h" #include "qgscodeeditorjson.h" #include diff --git a/src/gui/codeeditors/qgscodeeditorpython.cpp b/src/gui/codeeditors/qgscodeeditorpython.cpp index 288dfeac1b69..6dabd3499ac3 100644 --- a/src/gui/codeeditors/qgscodeeditorpython.cpp +++ b/src/gui/codeeditors/qgscodeeditorpython.cpp @@ -30,12 +30,12 @@ #include #include -QgsCodeEditorPython::QgsCodeEditorPython( QWidget *parent, const QList &filenames ) +QgsCodeEditorPython::QgsCodeEditorPython( QWidget *parent, const QList &filenames, Mode mode ) : QgsCodeEditor( parent, QString(), false, false, - QgsCodeEditor::Flag::CodeFolding ) + QgsCodeEditor::Flag::CodeFolding, mode ) , mAPISFilesList( filenames ) { if ( !parent ) diff --git a/src/gui/codeeditors/qgscodeeditorpython.h b/src/gui/codeeditors/qgscodeeditorpython.h index 8d7ea15e8d1b..5562f0a37869 100644 --- a/src/gui/codeeditors/qgscodeeditorpython.h +++ b/src/gui/codeeditors/qgscodeeditorpython.h @@ -58,7 +58,8 @@ class GUI_EXPORT QgsCodeEditorPython : public QgsCodeEditor * \param filenames The list of apis files to load for the Python lexer * \since QGIS 2.6 */ - QgsCodeEditorPython( QWidget *parent SIP_TRANSFERTHIS = nullptr, const QList &filenames = QList() ); + QgsCodeEditorPython( QWidget *parent SIP_TRANSFERTHIS = nullptr, const QList &filenames = QList(), + QgsCodeEditor::Mode mode = QgsCodeEditor::Mode::ScriptEditor ); Qgis::ScriptLanguage language() const override; diff --git a/src/gui/codeeditors/qgscodeeditorsql.cpp b/src/gui/codeeditors/qgscodeeditorsql.cpp index bacd28e7276c..5e3edcccdeac 100644 --- a/src/gui/codeeditors/qgscodeeditorsql.cpp +++ b/src/gui/codeeditors/qgscodeeditorsql.cpp @@ -13,9 +13,7 @@ * * ***************************************************************************/ -#include "qgsapplication.h" #include "qgscodeeditorsql.h" -#include "qgssymbollayerutils.h" #include #include @@ -35,7 +33,7 @@ QgsCodeEditorSQL::QgsCodeEditorSQL( QWidget *parent ) Qgis::ScriptLanguage QgsCodeEditorSQL::language() const { - return Qgis::ScriptLanguage::SQL; + return Qgis::ScriptLanguage::Sql; } QgsCodeEditorSQL::~QgsCodeEditorSQL() diff --git a/python/console/console_history_dlg.ui b/src/ui/qgscodeditorhistorydialogbase.ui similarity index 90% rename from python/console/console_history_dlg.ui rename to src/ui/qgscodeditorhistorydialogbase.ui index 3c6e0aff5351..116fe5a536f5 100644 --- a/python/console/console_history_dlg.ui +++ b/src/ui/qgscodeditorhistorydialogbase.ui @@ -1,7 +1,7 @@ - HistoryDialogPythonConsole - + QgsCodeEditorHistoryDialogBase + 0 @@ -27,7 +27,7 @@ 4 - + Reload @@ -37,7 +37,7 @@ - + true @@ -65,7 +65,7 @@ - + Run @@ -124,7 +124,7 @@ buttonBox accepted() - HistoryDialogPythonConsole + QgsCodeEditorHistoryDialogBase accept() @@ -140,7 +140,7 @@ buttonBox rejected() - HistoryDialogPythonConsole + QgsCodeEditorHistoryDialogBase reject()