QGIS/python/console/console_editor.py
Nyall Dawson 928bdd8cc4 Fix tooltip formatting for console actions
The tooltip text should be in bold, not the shortcut
2025-10-01 13:46:41 +10:00

1115 lines
43 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,
QgsGui,
QgsMessageBar,
QgsShortcutsManager,
)
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.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)
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(QKeySequence.StandardKey.HelpContents)
menu.addAction(context_help_action)
run_selection_action = QAction(menu)
run_selection_action.setIcon(
QgsApplication.getThemeIcon("mActionRunSelected.svg"),
)
run_selection_action.triggered.connect(self.runSelectedCode)
QgsGui.shortcutsManager().initializeCommonAction(
run_selection_action,
QgsShortcutsManager.CommonAction.CodeRunSelection,
)
menu.addAction(run_selection_action)
start_action = QAction(self)
start_action.setIcon(QgsApplication.getThemeIcon("mActionStart.svg"))
start_action.triggered.connect(self.runScriptCode)
QgsGui.shortcutsManager().initializeCommonAction(
start_action,
QgsShortcutsManager.CommonAction.CodeRunScript,
)
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)
find_action.setShortcut("Ctrl+F")
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(menu)
toggle_comment_action.setIcon(
QgsApplication.getThemeIcon(
"console/iconCommentEditorConsole.svg",
self.palette().color(QPalette.ColorRole.WindowText),
)
)
toggle_comment_action.triggered.connect(self.toggleComment)
QgsGui.shortcutsManager().initializeCommonAction(
toggle_comment_action,
QgsShortcutsManager.CommonAction.CodeToggleComment,
)
menu.addAction(toggle_comment_action)
reformat_code_action = QAction(menu)
reformat_code_action.setIcon(
QgsApplication.getThemeIcon("console/iconFormatCode.svg")
)
reformat_code_action.triggered.connect(self.reformatCode)
QgsGui.shortcutsManager().initializeCommonAction(
reformat_code_action,
QgsShortcutsManager.CommonAction.CodeReformat,
)
menu.addAction(reformat_code_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)
run_selection_action.setEnabled(False)
copyAction.setEnabled(False)
selectAllAction.setEnabled(False)
undoAction.setEnabled(False)
redoAction.setEnabled(False)
showCodeInspection.setEnabled(False)
if self.hasSelectedText():
run_selection_action.setEnabled(True)
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.object_inspector_action.setChecked(False)
else:
listObj.show()
self.console_widget.object_inspector_action.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.show_editor_action.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.save_file_action.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(f"<b>{txtToolTipNewTab}</b> (Ctrl+T)")
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.save_file_action.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.save_file_action.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
)
if self.console_widget.listClassMethod.isVisible():
self.console_widget.object_inspector_action.setChecked(objInspectorEnabled)
self.console_widget.object_inspector_action.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)