mirror of
https://github.com/qgis/QGIS.git
synced 2025-10-11 00:04:09 -04:00
This allows the editor to be opened in a proper detached process, avoiding the editor being closed when QGIS is exited. Move to a blocklist for terminal text editors instead of the fragile polling approach (which eg doesn't work if the editor is set to pycharm)
1026 lines
44 KiB
Python
1026 lines
44 KiB
Python
"""
|
|
/***************************************************************************
|
|
Python Console for QGIS
|
|
-------------------
|
|
begin : 2012-09-10
|
|
copyright : (C) 2012 by Salvatore Larosa
|
|
email : lrssvtml (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. *
|
|
* *
|
|
***************************************************************************/
|
|
Some portions of code were taken from https://code.google.com/p/pydee/
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import codecs
|
|
import importlib
|
|
import os
|
|
import pyclbr
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from typing import (
|
|
Optional,
|
|
TYPE_CHECKING
|
|
)
|
|
from functools import partial
|
|
from operator import itemgetter
|
|
from pathlib import Path
|
|
|
|
from qgis.core import Qgis, QgsApplication, QgsBlockingNetworkRequest, QgsSettings
|
|
from qgis.gui import (
|
|
QgsCodeEditorPython,
|
|
QgsCodeEditorWidget,
|
|
QgsMessageBar
|
|
)
|
|
|
|
from qgis.PyQt.Qsci import QsciScintilla
|
|
from qgis.PyQt.QtCore import (
|
|
pyqtSignal,
|
|
QByteArray,
|
|
QCoreApplication,
|
|
QDir,
|
|
QEvent,
|
|
QFileInfo,
|
|
QJsonDocument,
|
|
QSize,
|
|
Qt,
|
|
QUrl
|
|
)
|
|
from qgis.PyQt.QtGui import QKeySequence
|
|
from qgis.PyQt.QtNetwork import QNetworkRequest
|
|
from qgis.PyQt.QtWidgets import (
|
|
QAction,
|
|
QApplication,
|
|
QFileDialog,
|
|
QFrame,
|
|
QGridLayout,
|
|
QLabel,
|
|
QMenu,
|
|
QMessageBox,
|
|
QShortcut,
|
|
QSizePolicy,
|
|
QSpacerItem,
|
|
QTabWidget,
|
|
QToolButton,
|
|
QTreeWidgetItem,
|
|
QWidget,
|
|
)
|
|
from qgis.utils import OverrideCursor, iface
|
|
|
|
if TYPE_CHECKING:
|
|
from .console import PythonConsoleWidget
|
|
|
|
|
|
class Editor(QgsCodeEditorPython):
|
|
|
|
trigger_find = pyqtSignal()
|
|
|
|
def __init__(self,
|
|
editor_tab: EditorTab,
|
|
console_widget: PythonConsoleWidget,
|
|
tab_widget: EditorTabWidget):
|
|
super().__init__(editor_tab)
|
|
self.editor_tab: EditorTab = editor_tab
|
|
self.console_widget: PythonConsoleWidget = console_widget
|
|
self.tab_widget: EditorTabWidget = tab_widget
|
|
self.code_editor_widget: Optional[QgsCodeEditorWidget] = None
|
|
|
|
# recent modification time
|
|
self.lastModified = 0
|
|
|
|
self.setMinimumHeight(120)
|
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
|
|
# Disable default scintilla shortcuts
|
|
ctrl, shift = self.SCMOD_CTRL << 16, self.SCMOD_SHIFT << 16
|
|
self.SendScintilla(QsciScintilla.SCI_CLEARCMDKEY, ord('T') + ctrl) # Switch current line with the next one
|
|
self.SendScintilla(QsciScintilla.SCI_CLEARCMDKEY, ord('D') + ctrl) # Duplicate current line / selection
|
|
self.SendScintilla(QsciScintilla.SCI_CLEARCMDKEY, ord('L') + ctrl) # Delete current line
|
|
self.SendScintilla(QsciScintilla.SCI_CLEARCMDKEY, ord('L') + ctrl + shift)
|
|
|
|
# New QShortcut = ctrl+space/ctrl+alt+space for Autocomplete
|
|
self.newShortcutCS = QShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_Space), self)
|
|
self.newShortcutCS.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
self.redoScut = QShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Modifier.SHIFT | Qt.Key.Key_Z), self)
|
|
self.redoScut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
self.redoScut.activated.connect(self.redo)
|
|
self.newShortcutCS.activated.connect(self.autoComplete)
|
|
self.runScut = QShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_E), self)
|
|
self.runScut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
self.runScut.activated.connect(self.runSelectedCode) # spellok
|
|
self.runScriptScut = QShortcut(QKeySequence(Qt.Modifier.SHIFT | Qt.Modifier.CTRL | Qt.Key.Key_E), self)
|
|
self.runScriptScut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
self.runScriptScut.activated.connect(self.runScriptCode)
|
|
|
|
self.syntaxCheckScut = QShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_4), self)
|
|
self.syntaxCheckScut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
self.syntaxCheckScut.activated.connect(self.syntaxCheck)
|
|
self.modificationChanged.connect(self.editor_tab.modified)
|
|
self.modificationAttempted.connect(self.fileReadOnly)
|
|
|
|
def settingsEditor(self):
|
|
# Set Python lexer
|
|
self.initializeLexer()
|
|
|
|
def contextMenuEvent(self, e):
|
|
menu = QMenu(self)
|
|
menu.addAction(
|
|
QCoreApplication.translate("PythonConsole", "Hide Editor"),
|
|
self.hideEditor)
|
|
menu.addSeparator()
|
|
|
|
syntaxCheckAction = QAction(QgsApplication.getThemeIcon("console/iconSyntaxErrorConsole.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Check Syntax"),
|
|
menu)
|
|
syntaxCheckAction.triggered.connect(self.syntaxCheck)
|
|
syntaxCheckAction.setShortcut('Ctrl+4')
|
|
menu.addAction(syntaxCheckAction)
|
|
|
|
runSelected = QAction(QgsApplication.getThemeIcon("console/mIconRunConsole.svg"), # spellok
|
|
QCoreApplication.translate("PythonConsole", "Run Selected"),
|
|
menu)
|
|
runSelected.triggered.connect(self.runSelectedCode) # spellok
|
|
runSelected.setShortcut('Ctrl+E') # spellok
|
|
menu.addAction(runSelected) # spellok
|
|
|
|
pyQGISHelpAction = QAction(QgsApplication.getThemeIcon("console/iconHelpConsole.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Search Selection in PyQGIS Documentation"),
|
|
menu)
|
|
pyQGISHelpAction.triggered.connect(self.searchSelectedTextInPyQGISDocs)
|
|
menu.addAction(pyQGISHelpAction)
|
|
|
|
start_action = QAction(QgsApplication.getThemeIcon("mActionStart.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Run Script"),
|
|
menu)
|
|
start_action.triggered.connect(self.runScriptCode)
|
|
start_action.setShortcut('Ctrl+Shift+E')
|
|
menu.addAction(start_action)
|
|
|
|
menu.addSeparator()
|
|
undoAction = QAction(QgsApplication.getThemeIcon("mActionUndo.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Undo"),
|
|
menu)
|
|
undoAction.triggered.connect(self.undo)
|
|
undoAction.setShortcut(QKeySequence.StandardKey.Undo)
|
|
menu.addAction(undoAction)
|
|
|
|
redoAction = QAction(QgsApplication.getThemeIcon("mActionRedo.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Redo"),
|
|
menu)
|
|
redoAction.triggered.connect(self.redo)
|
|
redoAction.setShortcut('Ctrl+Shift+Z')
|
|
menu.addAction(redoAction)
|
|
|
|
menu.addSeparator()
|
|
find_action = QAction(
|
|
QgsApplication.getThemeIcon("console/iconSearchEditorConsole.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Find Text"),
|
|
menu)
|
|
find_action.triggered.connect(self.trigger_find)
|
|
menu.addAction(find_action)
|
|
|
|
cutAction = QAction(
|
|
QgsApplication.getThemeIcon("mActionEditCut.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Cut"),
|
|
menu)
|
|
cutAction.triggered.connect(self.cut)
|
|
cutAction.setShortcut(QKeySequence.StandardKey.Cut)
|
|
menu.addAction(cutAction)
|
|
|
|
copyAction = QAction(QgsApplication.getThemeIcon("mActionEditCopy.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Copy"),
|
|
menu)
|
|
copyAction.triggered.connect(self.copy)
|
|
copyAction.setShortcut(QKeySequence.StandardKey.Copy)
|
|
menu.addAction(copyAction)
|
|
|
|
pasteAction = QAction(QgsApplication.getThemeIcon("mActionEditPaste.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Paste"),
|
|
menu)
|
|
pasteAction.triggered.connect(self.paste)
|
|
pasteAction.setShortcut(QKeySequence.StandardKey.Paste)
|
|
menu.addAction(pasteAction)
|
|
|
|
selectAllAction = QAction(
|
|
QCoreApplication.translate("PythonConsole", "Select All"),
|
|
menu)
|
|
selectAllAction.triggered.connect(self.selectAll)
|
|
selectAllAction.setShortcut(QKeySequence.StandardKey.SelectAll)
|
|
menu.addAction(selectAllAction)
|
|
|
|
menu.addSeparator()
|
|
toggle_comment_action = QAction(
|
|
QgsApplication.getThemeIcon("console/iconCommentEditorConsole.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Toggle Comment"),
|
|
menu)
|
|
toggle_comment_action.triggered.connect(self.toggleComment)
|
|
toggle_comment_action.setShortcut('Ctrl+:')
|
|
menu.addAction(toggle_comment_action)
|
|
|
|
menu.addSeparator()
|
|
gist_menu = QMenu(self)
|
|
gist_menu.setTitle(QCoreApplication.translate("PythonConsole", "Share on GitHub"))
|
|
gist_menu.setIcon(QgsApplication.getThemeIcon("console/iconCodepadConsole.svg"))
|
|
gist_menu.addAction(QCoreApplication.translate("PythonConsole", "Secret Gist"),
|
|
partial(self.shareOnGist, False))
|
|
gist_menu.addAction(QCoreApplication.translate("PythonConsole", "Public Gist"),
|
|
partial(self.shareOnGist, True))
|
|
menu.addMenu(gist_menu)
|
|
showCodeInspection = menu.addAction(QgsApplication.getThemeIcon("console/iconClassBrowserConsole.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Hide/Show Object Inspector"),
|
|
self.objectListEditor)
|
|
menu.addSeparator()
|
|
menu.addAction(QgsApplication.getThemeIcon("console/iconSettingsConsole.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Options…"),
|
|
self.console_widget.openSettings)
|
|
syntaxCheckAction.setEnabled(False)
|
|
pasteAction.setEnabled(False)
|
|
pyQGISHelpAction.setEnabled(False)
|
|
gist_menu.setEnabled(False)
|
|
cutAction.setEnabled(False)
|
|
runSelected.setEnabled(False) # spellok
|
|
copyAction.setEnabled(False)
|
|
selectAllAction.setEnabled(False)
|
|
undoAction.setEnabled(False)
|
|
redoAction.setEnabled(False)
|
|
showCodeInspection.setEnabled(False)
|
|
if self.hasSelectedText():
|
|
runSelected.setEnabled(True) # spellok
|
|
copyAction.setEnabled(True)
|
|
cutAction.setEnabled(True)
|
|
gist_menu.setEnabled(True)
|
|
pyQGISHelpAction.setEnabled(True)
|
|
if not self.text() == '':
|
|
selectAllAction.setEnabled(True)
|
|
syntaxCheckAction.setEnabled(True)
|
|
if self.isUndoAvailable():
|
|
undoAction.setEnabled(True)
|
|
if self.isRedoAvailable():
|
|
redoAction.setEnabled(True)
|
|
if QApplication.clipboard().text():
|
|
pasteAction.setEnabled(True)
|
|
if QgsSettings().value("pythonConsole/enableObjectInsp",
|
|
False, type=bool):
|
|
showCodeInspection.setEnabled(True)
|
|
menu.exec(self.mapToGlobal(e.pos()))
|
|
|
|
def objectListEditor(self):
|
|
listObj = self.console_widget.listClassMethod
|
|
if listObj.isVisible():
|
|
listObj.hide()
|
|
self.console_widget.objectListButton.setChecked(False)
|
|
else:
|
|
listObj.show()
|
|
self.console_widget.objectListButton.setChecked(True)
|
|
|
|
def shareOnGist(self, is_public):
|
|
ACCESS_TOKEN = QgsSettings().value("pythonConsole/accessTokenGithub", '', type=QByteArray)
|
|
if not ACCESS_TOKEN:
|
|
msg_text = QCoreApplication.translate(
|
|
'PythonConsole', 'GitHub personal access token must be generated (see Console Options)')
|
|
self.showMessage(msg_text,
|
|
level=Qgis.MessageLevel.Warning)
|
|
return
|
|
|
|
URL = "https://api.github.com/gists"
|
|
|
|
path = self.tab_widget.currentWidget().file_path()
|
|
filename = os.path.basename(path) if path else None
|
|
filename = filename if filename else "pyqgis_snippet.py"
|
|
|
|
selected_text = self.selectedText()
|
|
data = {"description": "Gist created by PyQGIS Console",
|
|
"public": is_public,
|
|
"files": {filename: {"content": selected_text}}}
|
|
|
|
request = QgsBlockingNetworkRequest()
|
|
net_req = QNetworkRequest()
|
|
url = QUrl(URL)
|
|
net_req.setUrl(url)
|
|
net_req.setRawHeader(b"Authorization", b"token %s" % ACCESS_TOKEN)
|
|
err = request.post(net_req, QJsonDocument(data).toJson())
|
|
if not err:
|
|
response = request.reply().content()
|
|
json_doc = QJsonDocument()
|
|
_json = json_doc.fromJson(response)
|
|
link = _json.object()['html_url'].toString()
|
|
QApplication.clipboard().setText(link)
|
|
msg = QCoreApplication.translate('PythonConsole', 'URL copied to clipboard.')
|
|
self.showMessage(msg)
|
|
else:
|
|
msg = QCoreApplication.translate('PythonConsole', 'Connection error: ')
|
|
self.showMessage(msg + request.errorMessage(),
|
|
level=Qgis.MessageLevel.Warning)
|
|
|
|
def hideEditor(self):
|
|
self.console_widget.splitterObj.hide()
|
|
self.console_widget.showEditorButton.setChecked(False)
|
|
|
|
def createTempFile(self):
|
|
name = tempfile.NamedTemporaryFile(delete=False).name
|
|
# Need to use newline='' to avoid adding extra \r characters on Windows
|
|
with open(name, 'w', encoding='utf-8', newline='') as f:
|
|
f.write(self.text())
|
|
return name
|
|
|
|
def runScriptCode(self):
|
|
autoSave = QgsSettings().value("pythonConsole/autoSaveScript", False, type=bool)
|
|
filename = self.code_editor_widget.filePath()
|
|
filename_override = None
|
|
msgEditorBlank = QCoreApplication.translate('PythonConsole',
|
|
'Hey, type something to run!')
|
|
if filename is None:
|
|
if not self.isModified():
|
|
self.showMessage(msgEditorBlank)
|
|
return
|
|
|
|
deleteTempFile = False
|
|
if self.syntaxCheck():
|
|
if filename and self.isModified() and autoSave:
|
|
self.save(filename)
|
|
elif not filename or self.isModified():
|
|
# Create a new temp file if the file isn't already saved.
|
|
filename = self.createTempFile()
|
|
filename_override = self.tab_widget.tabText(self.tab_widget.currentIndex())
|
|
if filename_override.startswith('*'):
|
|
filename_override = filename_override[1:]
|
|
deleteTempFile = True
|
|
|
|
self.console_widget.shell.runFile(filename, filename_override)
|
|
|
|
if deleteTempFile:
|
|
Path(filename).unlink()
|
|
|
|
def runSelectedCode(self): # spellok
|
|
cmd = self.selectedText()
|
|
self.console_widget.shell.insertFromDropPaste(cmd)
|
|
self.console_widget.shell.entered()
|
|
self.setFocus()
|
|
|
|
def getTextFromEditor(self):
|
|
text = self.text()
|
|
textList = text.split("\n")
|
|
return textList
|
|
|
|
def goToLine(self, objName, linenr):
|
|
self.SendScintilla(QsciScintilla.SCI_GOTOLINE, linenr - 1)
|
|
self.SendScintilla(QsciScintilla.SCI_SETTARGETSTART,
|
|
self.SendScintilla(QsciScintilla.SCI_GETCURRENTPOS))
|
|
self.SendScintilla(QsciScintilla.SCI_SETTARGETEND, len(self.text()))
|
|
pos = self.SendScintilla(QsciScintilla.SCI_SEARCHINTARGET, len(objName), objName)
|
|
index = pos - self.SendScintilla(QsciScintilla.SCI_GETCURRENTPOS)
|
|
# line, _ = self.getCursorPosition()
|
|
self.setSelection(linenr - 1, index, linenr - 1, index + len(objName))
|
|
self.ensureLineVisible(linenr)
|
|
self.setFocus()
|
|
|
|
def syntaxCheck(self):
|
|
self.code_editor_widget.clearWarnings()
|
|
source = self.text().encode("utf-8")
|
|
try:
|
|
compile(source, "", "exec")
|
|
except SyntaxError as detail:
|
|
eline = detail.lineno or 1
|
|
eline -= 1
|
|
ecolumn = detail.offset or 1
|
|
edescr = detail.msg
|
|
self.code_editor_widget.addWarning(eline, edescr)
|
|
self.setCursorPosition(eline, ecolumn - 1)
|
|
self.ensureLineVisible(eline)
|
|
return False
|
|
|
|
return True
|
|
|
|
def focusInEvent(self, e):
|
|
if self.code_editor_widget.filePath():
|
|
if not QFileInfo(self.code_editor_widget.filePath()).exists():
|
|
msgText = QCoreApplication.translate('PythonConsole',
|
|
'The file <b>"{0}"</b> has been deleted or is not accessible').format(self.code_editor_widget.filePath())
|
|
self.showMessage(msgText,
|
|
level=Qgis.MessageLevel.Critical)
|
|
return
|
|
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.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.code_editor_widget.filePath()).lastModified()
|
|
super().focusInEvent(e)
|
|
|
|
def fileReadOnly(self):
|
|
msgText = QCoreApplication.translate('PythonConsole',
|
|
'The file <b>"{0}"</b> 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.code_editor_widget.setFilePath(filename)
|
|
self.setText(Path(filename).read_text(encoding='utf-8'))
|
|
self.setReadOnly(read_only)
|
|
self.setModified(False)
|
|
self.recolor()
|
|
|
|
def save(self, filename: Optional[str] = None):
|
|
if self.isReadOnly():
|
|
return
|
|
|
|
if QgsSettings().value("pythonConsole/formatOnSave", False, type=bool):
|
|
self.reformatCode()
|
|
|
|
index = self.tab_widget.indexOf(self.editor_tab)
|
|
if filename:
|
|
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())
|
|
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 path:
|
|
self.code_editor_widget.setFilePath(None)
|
|
return
|
|
self.code_editor_widget.setFilePath(path)
|
|
|
|
msgText = QCoreApplication.translate('PythonConsole',
|
|
'Script was correctly saved.')
|
|
self.showMessage(msgText)
|
|
|
|
# Save the new contents
|
|
# Need to use newline='' to avoid adding extra \r characters on Windows
|
|
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.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.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.code_editor_widget.filePath()).parent.as_posix())
|
|
|
|
def event(self, e):
|
|
""" Used to override the Application shortcuts when the editor has focus """
|
|
|
|
if e.type() == QEvent.Type.ShortcutOverride:
|
|
ctrl = e.modifiers() == Qt.KeyboardModifier.ControlModifier
|
|
ctrl_shift = e.modifiers() == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)
|
|
if (
|
|
(ctrl and e.key() == Qt.Key.Key_W)
|
|
or (ctrl_shift and e.key() == Qt.Key.Key_W)
|
|
or (ctrl and e.key() == Qt.Key.Key_S)
|
|
or (ctrl_shift and e.key() == Qt.Key.Key_S)
|
|
or (ctrl and e.key() == Qt.Key.Key_T)
|
|
or (ctrl and e.key() == Qt.Key.Key_Tab)
|
|
):
|
|
e.accept()
|
|
return True
|
|
|
|
return super().event(e)
|
|
|
|
def keyPressEvent(self, e):
|
|
ctrl = e.modifiers() == Qt.KeyboardModifier.ControlModifier
|
|
ctrl_shift = e.modifiers() == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)
|
|
|
|
# Ctrl+W: close current tab
|
|
if ctrl and e.key() == Qt.Key.Key_W:
|
|
self.editor_tab.close()
|
|
|
|
# Ctrl+Shift+W: close all tabs
|
|
if ctrl_shift and e.key() == Qt.Key.Key_W:
|
|
self.tab_widget.closeAll()
|
|
|
|
# Ctrl+S: save current tab
|
|
if ctrl and e.key() == Qt.Key.Key_S:
|
|
self.save()
|
|
|
|
# Ctrl+Shift+S: save current tab as
|
|
if ctrl_shift and e.key() == Qt.Key.Key_S:
|
|
self.tab_widget.saveAs()
|
|
|
|
# Ctrl+T: open new tab
|
|
if ctrl and e.key() == Qt.Key.Key_T:
|
|
self.tab_widget.newTabEditor()
|
|
|
|
super().keyPressEvent(e)
|
|
|
|
def showMessage(self,
|
|
text: str,
|
|
title: Optional[str] = None,
|
|
level=Qgis.MessageLevel.Info):
|
|
self.editor_tab.showMessage(text, level, title=title)
|
|
|
|
|
|
class EditorTab(QWidget):
|
|
|
|
search_bar_toggled = pyqtSignal(bool)
|
|
|
|
def __init__(self,
|
|
tab_widget: EditorTabWidget,
|
|
console_widget: PythonConsoleWidget,
|
|
filename: Optional[str],
|
|
read_only: bool):
|
|
super().__init__(tab_widget)
|
|
self.tab_widget: EditorTabWidget = tab_widget
|
|
|
|
self._editor = Editor(editor_tab=self,
|
|
console_widget=console_widget,
|
|
tab_widget=tab_widget)
|
|
|
|
self._editor_code_widget = QgsCodeEditorWidget(
|
|
self._editor
|
|
)
|
|
self._editor.code_editor_widget = self._editor_code_widget
|
|
self._editor_code_widget.searchBarToggled.connect(
|
|
self.search_bar_toggled
|
|
)
|
|
|
|
self._editor.trigger_find.connect(
|
|
self._editor_code_widget.triggerFind
|
|
)
|
|
|
|
if filename:
|
|
if QFileInfo(filename).exists():
|
|
self._editor.loadFile(filename, read_only)
|
|
|
|
self.tabLayout = QGridLayout(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)
|
|
|
|
def search_bar_visible(self) -> bool:
|
|
"""
|
|
Returns True if the tab's search bar is visible
|
|
"""
|
|
return self._editor_code_widget.isSearchBarVisible()
|
|
|
|
def trigger_find(self):
|
|
"""
|
|
Triggers a find operation using the default behavior
|
|
"""
|
|
self._editor_code_widget.triggerFind()
|
|
|
|
def hide_search_bar(self):
|
|
"""
|
|
Hides the search bar
|
|
"""
|
|
self._editor_code_widget.hideSearchBar()
|
|
|
|
def close(self):
|
|
self.tab_widget._removeTab(self, tab2index=True)
|
|
|
|
def __getattr__(self, name):
|
|
""" Forward all missing attribute requests to the editor."""
|
|
try:
|
|
return super().__getattr__(name)
|
|
except AttributeError:
|
|
return getattr(self._editor, name)
|
|
|
|
def __setattr__(self, name, value):
|
|
""" Forward all missing attribute requests to the editor."""
|
|
try:
|
|
return super().__setattr__(name, value)
|
|
except AttributeError:
|
|
return setattr(self._editor, name, value)
|
|
|
|
def showMessage(self, text, level=Qgis.MessageLevel.Info, timeout=-1, title=""):
|
|
self._editor_code_widget.messageBar().pushMessage(title, text, level, timeout)
|
|
|
|
|
|
class EditorTabWidget(QTabWidget):
|
|
|
|
search_bar_toggled = pyqtSignal(bool)
|
|
|
|
def __init__(self, console_widget: PythonConsoleWidget):
|
|
super().__init__(parent=None)
|
|
self.console_widget: PythonConsoleWidget = console_widget
|
|
|
|
self.idx = -1
|
|
# Layout for top frame (restore tabs)
|
|
self.layoutTopFrame = QGridLayout(self)
|
|
self.layoutTopFrame.setContentsMargins(0, 0, 0, 0)
|
|
spacerItem = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
|
|
self.layoutTopFrame.addItem(spacerItem, 1, 0, 1, 1)
|
|
self.topFrame = QFrame(self)
|
|
self.topFrame.setStyleSheet('background-color: rgb(255, 255, 230);')
|
|
self.topFrame.setFrameShape(QFrame.Shape.StyledPanel)
|
|
self.topFrame.setMinimumHeight(24)
|
|
self.layoutTopFrame2 = QGridLayout(self.topFrame)
|
|
self.layoutTopFrame2.setContentsMargins(0, 0, 0, 0)
|
|
label = QCoreApplication.translate("PythonConsole",
|
|
"Click on button to restore all tabs from last session.")
|
|
self.label = QLabel(label)
|
|
|
|
self.restoreTabsButton = QToolButton()
|
|
toolTipRestore = QCoreApplication.translate("PythonConsole",
|
|
"Restore tabs")
|
|
self.restoreTabsButton.setToolTip(toolTipRestore)
|
|
self.restoreTabsButton.setIcon(QgsApplication.getThemeIcon("console/iconRestoreTabsConsole.svg"))
|
|
self.restoreTabsButton.setIconSize(QSize(24, 24))
|
|
self.restoreTabsButton.setAutoRaise(True)
|
|
self.restoreTabsButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.restoreTabsButton.setStyleSheet('QToolButton:hover{border: none } \
|
|
QToolButton:pressed{border: none}')
|
|
|
|
self.clButton = QToolButton()
|
|
toolTipClose = QCoreApplication.translate("PythonConsole",
|
|
"Close")
|
|
self.clButton.setToolTip(toolTipClose)
|
|
self.clButton.setIcon(QgsApplication.getThemeIcon("/mIconClose.svg"))
|
|
self.clButton.setIconSize(QSize(18, 18))
|
|
self.clButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.clButton.setStyleSheet('QToolButton:hover{border: none } \
|
|
QToolButton:pressed{border: none}')
|
|
self.clButton.setAutoRaise(True)
|
|
|
|
sizePolicy = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
|
self.topFrame.setSizePolicy(sizePolicy)
|
|
self.layoutTopFrame.addWidget(self.topFrame, 0, 0, 1, 1)
|
|
self.layoutTopFrame2.addWidget(self.label, 0, 1, 1, 1)
|
|
self.layoutTopFrame2.addWidget(self.restoreTabsButton, 0, 0, 1, 1)
|
|
self.layoutTopFrame2.addWidget(self.clButton, 0, 2, 1, 1)
|
|
|
|
self.topFrame.hide()
|
|
self.restoreTabsButton.clicked.connect(self.restoreTabs)
|
|
self.clButton.clicked.connect(self.closeRestore)
|
|
|
|
# Fixes #7653
|
|
if sys.platform != 'darwin':
|
|
self.setDocumentMode(True)
|
|
|
|
self.setMovable(True)
|
|
self.setTabsClosable(True)
|
|
self.setTabPosition(QTabWidget.TabPosition.North)
|
|
|
|
# Menu button list tabs
|
|
self.fileTabMenu = QMenu()
|
|
self.fileTabMenu.aboutToShow.connect(self.showFileTabMenu)
|
|
self.fileTabMenu.triggered.connect(self.showFileTabMenuTriggered)
|
|
self.fileTabButton = QToolButton()
|
|
txtToolTipMenuFile = QCoreApplication.translate("PythonConsole",
|
|
"List all tabs")
|
|
self.fileTabButton.setToolTip(txtToolTipMenuFile)
|
|
self.fileTabButton.setIcon(QgsApplication.getThemeIcon("console/iconFileTabsMenuConsole.svg"))
|
|
self.fileTabButton.setIconSize(QSize(24, 24))
|
|
self.fileTabButton.setAutoRaise(True)
|
|
self.fileTabButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
self.fileTabButton.setMenu(self.fileTabMenu)
|
|
self.setCornerWidget(self.fileTabButton, Qt.Corner.TopRightCorner)
|
|
self.tabCloseRequested.connect(self._removeTab)
|
|
self.currentChanged.connect(self._currentWidgetChanged)
|
|
|
|
# New Editor button
|
|
self.newTabButton = QToolButton()
|
|
txtToolTipNewTab = QCoreApplication.translate("PythonConsole",
|
|
"New Editor")
|
|
self.newTabButton.setToolTip(txtToolTipNewTab)
|
|
self.newTabButton.setAutoRaise(True)
|
|
self.newTabButton.setIcon(QgsApplication.getThemeIcon("console/iconNewTabEditorConsole.svg"))
|
|
self.newTabButton.setIconSize(QSize(24, 24))
|
|
self.setCornerWidget(self.newTabButton, Qt.Corner.TopLeftCorner)
|
|
self.newTabButton.clicked.connect(self.newTabEditor)
|
|
|
|
def _currentWidgetChanged(self, tab):
|
|
if QgsSettings().value("pythonConsole/enableObjectInsp",
|
|
False, type=bool):
|
|
self.listObject(tab)
|
|
self.changeLastDirPath(tab)
|
|
self.enableSaveIfModified(tab)
|
|
|
|
if self.currentWidget():
|
|
self.search_bar_toggled.emit(
|
|
self.currentWidget().search_bar_visible()
|
|
)
|
|
|
|
def toggle_search_bar(self, visible: bool):
|
|
"""
|
|
Toggles whether the search bar should be visible
|
|
"""
|
|
if visible and not self.currentWidget().search_bar_visible():
|
|
self.currentWidget().trigger_find()
|
|
elif not visible and self.currentWidget().search_bar_visible():
|
|
self.currentWidget().hide_search_bar()
|
|
|
|
def contextMenuEvent(self, e):
|
|
tabBar = self.tabBar()
|
|
self.idx = tabBar.tabAt(e.pos())
|
|
if self.widget(self.idx):
|
|
cW = self.widget(self.idx)
|
|
menu = QMenu(self)
|
|
menu.addSeparator()
|
|
menu.addAction(
|
|
QCoreApplication.translate("PythonConsole", "New Editor"),
|
|
self.newTabEditor)
|
|
menu.addSeparator()
|
|
closeTabAction = menu.addAction(
|
|
QCoreApplication.translate("PythonConsole", "Close Tab"),
|
|
cW.close)
|
|
closeAllTabAction = menu.addAction(
|
|
QCoreApplication.translate("PythonConsole", "Close All"),
|
|
self.closeAll)
|
|
closeOthersTabAction = menu.addAction(
|
|
QCoreApplication.translate("PythonConsole", "Close Others"),
|
|
self.closeOthers)
|
|
menu.addSeparator()
|
|
saveAction = menu.addAction(
|
|
QCoreApplication.translate("PythonConsole", "Save"),
|
|
cW.save)
|
|
menu.addAction(
|
|
QCoreApplication.translate("PythonConsole", "Save As"),
|
|
self.saveAs)
|
|
closeTabAction.setEnabled(False)
|
|
closeAllTabAction.setEnabled(False)
|
|
closeOthersTabAction.setEnabled(False)
|
|
saveAction.setEnabled(False)
|
|
if self.count() > 1:
|
|
closeTabAction.setEnabled(True)
|
|
closeAllTabAction.setEnabled(True)
|
|
closeOthersTabAction.setEnabled(True)
|
|
if self.widget(self.idx).isModified():
|
|
saveAction.setEnabled(True)
|
|
menu.exec(self.mapToGlobal(e.pos()))
|
|
|
|
def closeOthers(self):
|
|
idx = self.idx
|
|
countTab = self.count()
|
|
for i in list(range(countTab - 1, idx, -1)) + list(range(idx - 1, -1, -1)):
|
|
self._removeTab(i)
|
|
|
|
def closeAll(self):
|
|
countTab = self.count()
|
|
for i in range(countTab - 1, 0, -1):
|
|
self._removeTab(i)
|
|
|
|
self.newTabEditor(tabName=QCoreApplication.translate("PythonConsole", "Untitled-0"))
|
|
self._removeTab(0)
|
|
|
|
def saveAs(self):
|
|
self.console_widget.saveAsScriptFile(self.idx)
|
|
|
|
def enableSaveIfModified(self, tab):
|
|
tabWidget = self.widget(tab)
|
|
if tabWidget:
|
|
self.console_widget.saveFileButton.setEnabled(tabWidget.isModified())
|
|
|
|
def enableToolBarEditor(self, enable):
|
|
if self.topFrame.isVisible():
|
|
enable = False
|
|
self.console_widget.toolBarEditor.setEnabled(enable)
|
|
|
|
def newTabEditor(self, tabName=None, filename: Optional[str] = None):
|
|
read_only = False
|
|
if filename:
|
|
read_only = not QFileInfo(filename).isWritable()
|
|
try:
|
|
fn = codecs.open(filename, "rb", encoding='utf-8')
|
|
fn.read()
|
|
fn.close()
|
|
except IOError as error:
|
|
IOErrorTr = QCoreApplication.translate('PythonConsole',
|
|
'The file {0} could not be opened. Error: {1}\n').format(filename,
|
|
error.strerror)
|
|
print('## Error: ')
|
|
sys.stderr.write(IOErrorTr)
|
|
return
|
|
|
|
nr = self.count()
|
|
if not tabName:
|
|
tabName = QCoreApplication.translate('PythonConsole', 'Untitled-{0}').format(nr)
|
|
tab = EditorTab(tab_widget=self,
|
|
console_widget=self.console_widget,
|
|
filename=filename,
|
|
read_only=read_only)
|
|
self.iconTab = QgsApplication.getThemeIcon('console/iconTabEditorConsole.svg')
|
|
self.addTab(tab, self.iconTab, tabName + ' (ro)' if read_only else tabName)
|
|
self.setCurrentWidget(tab)
|
|
if filename:
|
|
self.setTabToolTip(self.currentIndex(), filename)
|
|
else:
|
|
self.setTabToolTip(self.currentIndex(), tabName)
|
|
|
|
tab.search_bar_toggled.connect(self._tab_search_bar_toggled)
|
|
|
|
def _tab_search_bar_toggled(self, visible: bool):
|
|
if self.sender() != self.currentWidget():
|
|
return
|
|
|
|
self.search_bar_toggled.emit(visible)
|
|
|
|
def tabModified(self, tab, modified):
|
|
index = self.indexOf(tab)
|
|
s = self.tabText(index)
|
|
self.setTabTitle(index, '*{}'.format(s) if modified else re.sub(r'^(\*)', '', s))
|
|
self.console_widget.saveFileButton.setEnabled(modified)
|
|
|
|
def setTabTitle(self, tab, title):
|
|
self.setTabText(tab, title)
|
|
|
|
def _removeTab(self, tab, tab2index=False):
|
|
if tab2index:
|
|
tab = self.indexOf(tab)
|
|
editorTab = self.widget(tab)
|
|
if editorTab.isModified():
|
|
txtSaveOnRemove = QCoreApplication.translate("PythonConsole",
|
|
"Python Console: Save File")
|
|
txtMsgSaveOnRemove = QCoreApplication.translate("PythonConsole",
|
|
"The file <b>'{0}'</b> has been modified, save changes?").format(self.tabText(tab))
|
|
res = QMessageBox.question(self, txtSaveOnRemove,
|
|
txtMsgSaveOnRemove,
|
|
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
|
|
if res == QMessageBox.StandardButton.Cancel:
|
|
return
|
|
if res == QMessageBox.StandardButton.Save:
|
|
editorTab.save()
|
|
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.code_editor_widget.filePath():
|
|
self.console_widget.updateTabListScript(editorTab.code_editor_widget.filePath(), action='remove')
|
|
if self.count() <= 1:
|
|
self.removeTab(tab)
|
|
self.newTabEditor()
|
|
else:
|
|
self.removeTab(tab)
|
|
|
|
editorTab.deleteLater()
|
|
self.currentWidget()._editor.setFocus(Qt.FocusReason.TabFocusReason)
|
|
|
|
def restoreTabsOrAddNew(self):
|
|
"""
|
|
Restore tabs if they are found in the settings. If none are found it will add a new empty tab.
|
|
"""
|
|
# Restore scripts from the previous session
|
|
tabScripts = QgsSettings().value("pythonConsole/tabScripts", [])
|
|
self.restoreTabList = tabScripts
|
|
|
|
if self.restoreTabList:
|
|
self.restoreTabs()
|
|
else:
|
|
self.newTabEditor(filename=None)
|
|
|
|
def restoreTabs(self):
|
|
for script in self.restoreTabList:
|
|
pathFile = script
|
|
if QFileInfo(pathFile).exists():
|
|
tabName = pathFile.split('/')[-1]
|
|
self.newTabEditor(tabName, pathFile)
|
|
else:
|
|
errOnRestore = QCoreApplication.translate("PythonConsole",
|
|
"Unable to restore the file: \n{0}\n").format(pathFile)
|
|
print('## Error: ')
|
|
s = errOnRestore
|
|
sys.stderr.write(s)
|
|
self.console_widget.updateTabListScript(pathFile, action='remove')
|
|
if self.count() < 1:
|
|
self.newTabEditor(filename=None)
|
|
self.topFrame.close()
|
|
self.enableToolBarEditor(True)
|
|
self.currentWidget()._editor.setFocus(Qt.FocusReason.TabFocusReason)
|
|
|
|
def closeRestore(self):
|
|
self.console_widget.updateTabListScript(None)
|
|
self.topFrame.close()
|
|
self.newTabEditor(filename=None)
|
|
self.enableToolBarEditor(True)
|
|
|
|
def showFileTabMenu(self):
|
|
self.fileTabMenu.clear()
|
|
for index in range(self.count()):
|
|
action = self.fileTabMenu.addAction(self.tabIcon(index), self.tabText(index))
|
|
action.setData(index)
|
|
|
|
def showFileTabMenuTriggered(self, action):
|
|
index = action.data()
|
|
if index is not None:
|
|
self.setCurrentIndex(index)
|
|
|
|
def listObject(self, tab):
|
|
self.console_widget.listClassMethod.clear()
|
|
if isinstance(tab, EditorTab):
|
|
tabWidget = self.widget(self.indexOf(tab))
|
|
else:
|
|
tabWidget = self.widget(tab)
|
|
if tabWidget:
|
|
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:
|
|
sys.path.append(pathFile)
|
|
found = True
|
|
try:
|
|
importlib.reload(pyclbr) # NOQA
|
|
dictObject = {}
|
|
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.file_path()):
|
|
superClassName = []
|
|
for superClass in class_data.super:
|
|
if superClass == 'object':
|
|
continue
|
|
if isinstance(superClass, str):
|
|
superClassName.append(superClass)
|
|
else:
|
|
superClassName.append(superClass.name)
|
|
classItem = QTreeWidgetItem()
|
|
if superClassName:
|
|
super = ', '.join([i for i in superClassName])
|
|
classItem.setText(0, name + ' [' + super + ']')
|
|
classItem.setToolTip(0, name + ' [' + super + ']')
|
|
else:
|
|
classItem.setText(0, name)
|
|
classItem.setToolTip(0, name)
|
|
if sys.platform.startswith('win'):
|
|
classItem.setSizeHint(0, QSize(18, 18))
|
|
classItem.setText(1, str(class_data.lineno))
|
|
iconClass = QgsApplication.getThemeIcon("console/iconClassTreeWidgetConsole.svg")
|
|
classItem.setIcon(0, iconClass)
|
|
dictObject[name] = class_data.lineno
|
|
for meth, lineno in sorted(list(class_data.methods.items()), key=itemgetter(1)):
|
|
methodItem = QTreeWidgetItem()
|
|
methodItem.setText(0, meth + ' ')
|
|
methodItem.setText(1, str(lineno))
|
|
methodItem.setToolTip(0, meth)
|
|
iconMeth = QgsApplication.getThemeIcon("console/iconMethodTreeWidgetConsole.svg")
|
|
methodItem.setIcon(0, iconMeth)
|
|
if sys.platform.startswith('win'):
|
|
methodItem.setSizeHint(0, QSize(18, 18))
|
|
classItem.addChild(methodItem)
|
|
dictObject[meth] = lineno
|
|
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.file_path()):
|
|
funcItem = QTreeWidgetItem()
|
|
funcItem.setText(0, func_name + ' ')
|
|
funcItem.setText(1, str(data.lineno))
|
|
funcItem.setToolTip(0, func_name)
|
|
iconFunc = QgsApplication.getThemeIcon("console/iconFunctionTreeWidgetConsole.svg")
|
|
funcItem.setIcon(0, iconFunc)
|
|
if sys.platform.startswith('win'):
|
|
funcItem.setSizeHint(0, QSize(18, 18))
|
|
dictObject[func_name] = data.lineno
|
|
self.console_widget.listClassMethod.addTopLevelItem(funcItem)
|
|
if found:
|
|
sys.path.remove(pathFile)
|
|
except:
|
|
msgItem = QTreeWidgetItem()
|
|
msgItem.setText(0, QCoreApplication.translate("PythonConsole", "Check Syntax"))
|
|
msgItem.setText(1, 'syntaxError')
|
|
iconWarning = QgsApplication.getThemeIcon("console/iconSyntaxErrorConsole.svg")
|
|
msgItem.setIcon(0, iconWarning)
|
|
self.console_widget.listClassMethod.addTopLevelItem(msgItem)
|
|
|
|
def refreshSettingsEditor(self):
|
|
objInspectorEnabled = QgsSettings().value("pythonConsole/enableObjectInsp",
|
|
False, type=bool)
|
|
listObj = self.console_widget.objectListButton
|
|
if self.console_widget.listClassMethod.isVisible():
|
|
listObj.setChecked(objInspectorEnabled)
|
|
listObj.setEnabled(objInspectorEnabled)
|
|
if objInspectorEnabled:
|
|
cW = self.currentWidget()
|
|
if cW and not self.console_widget.listClassMethod.isVisible():
|
|
with OverrideCursor(Qt.CursorShape.WaitCursor):
|
|
self.listObject(cW)
|
|
|
|
def changeLastDirPath(self, tab):
|
|
tabWidget = self.widget(tab)
|
|
if tabWidget and tabWidget.file_path():
|
|
QgsSettings().setValue("pythonConsole/lastDirPath",
|
|
Path(tabWidget.file_path()).parent.as_posix())
|
|
|
|
def showMessage(self, text, level=Qgis.MessageLevel.Info, timeout=-1, title=""):
|
|
currWidget = self.currentWidget()
|
|
currWidget.showMessage(text, level, timeout, title)
|