From 5743ee9c954bb27daa0035e08fe7be55b376225c Mon Sep 17 00:00:00 2001 From: Trex Date: Thu, 18 Sep 2025 18:15:47 +0200 Subject: [PATCH] Follow-up to Fix #40120: Setting shortcut moved from console_editor.py to console.py --- python/console/console_editor.py | 1093 ++++++++++++++++++++++++++++++ 1 file changed, 1093 insertions(+) create mode 100644 python/console/console_editor.py diff --git a/python/console/console_editor.py b/python/console/console_editor.py new file mode 100644 index 00000000000..c1bd21d9ce6 --- /dev/null +++ b/python/console/console_editor.py @@ -0,0 +1,1093 @@ +""" +/*************************************************************************** +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.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("mActionRunSelected.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(QKeySequence.StandardKey.HelpContents) + 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 "{0}" 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 '{0}' 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)