mirror of
https://github.com/qgis/QGIS.git
synced 2025-02-23 00:02:38 -05:00
1097 lines
42 KiB
Python
1097 lines
42 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, QColor, QPalette
|
|
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: QgsCodeEditorWidget | None = None
|
|
|
|
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 showApiDocumentation(self, text):
|
|
self.console_widget.shell.showApiDocumentation(text)
|
|
|
|
def set_code_editor_widget(self, widget: QgsCodeEditorWidget):
|
|
self.code_editor_widget = widget
|
|
self.code_editor_widget.loadedExternalChanges.connect(
|
|
self.loaded_external_changes
|
|
)
|
|
|
|
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
|
|
|
|
word = self.selectedText() or self.wordAtPoint(e.pos())
|
|
if word:
|
|
context_help_action = QAction(
|
|
QgsApplication.getThemeIcon("mActionHelpContents.svg"),
|
|
QCoreApplication.translate("PythonConsole", "Context Help"),
|
|
menu,
|
|
)
|
|
context_help_action.triggered.connect(
|
|
partial(
|
|
self.console_widget.shell.showApiDocumentation,
|
|
word,
|
|
force_search=True,
|
|
)
|
|
)
|
|
context_help_action.setShortcut("F1")
|
|
menu.addAction(context_help_action)
|
|
|
|
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",
|
|
self.palette().color(QPalette.ColorRole.WindowText),
|
|
),
|
|
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)
|
|
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)
|
|
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):
|
|
self.code_editor_widget.shareOnGist(is_public)
|
|
|
|
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 loaded_external_changes(self):
|
|
self.tab_widget.listObject(self.tab_widget.currentWidget())
|
|
|
|
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 save(self, filename: str | None = None):
|
|
if self.isReadOnly():
|
|
return
|
|
|
|
if QgsSettings().value("pythonConsole/formatOnSave", False, type=bool):
|
|
self.reformatCode()
|
|
|
|
index = self.tab_widget.indexOf(self.editor_tab)
|
|
if not filename and 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
|
|
filename = path
|
|
|
|
self.code_editor_widget.save(filename)
|
|
|
|
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.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: str | None = 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: str | None,
|
|
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.set_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_code_widget.loadFile(filename)
|
|
if read_only:
|
|
self._editor.setReadOnly(True)
|
|
|
|
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) -> str | None:
|
|
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: str | None = 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 OSError 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, f"*{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)
|