diff --git a/preditor/gui/codehighlighter.py b/preditor/gui/codehighlighter.py index 2da069b3..6c99fdaa 100644 --- a/preditor/gui/codehighlighter.py +++ b/preditor/gui/codehighlighter.py @@ -1,13 +1,12 @@ from __future__ import absolute_import -import json import keyword import os import re from Qt.QtGui import QColor, QSyntaxHighlighter, QTextCharFormat -from .. import resourcePath +from .. import resourcePath, utils class CodeHighlighter(QSyntaxHighlighter): @@ -28,6 +27,8 @@ def initHighlightVariables(self): # comment, do not highlight it). self.spans = [] + self._enabled = True + # Language specific lists self._comments = [] self._keywords = [] @@ -52,11 +53,17 @@ def initHighlightVariables(self): self._resultColor = QColor(125, 128, 128) self._stringColor = QColor(255, 128, 0) + def enabled(self): + return self._enabled + + def setEnabled(self, state): + self._enabled = state + def setLanguage(self, lang): """Sets the language of the highlighter by loading the json definition""" filename = resourcePath('lang/%s.json' % lang.lower()) if os.path.exists(filename): - data = json.load(open(filename)) + data = utils.Json(filename).load() self.setObjectName(data.get('name', '')) self._comments = data.get('comments', []) @@ -91,6 +98,9 @@ def highlightBlock(self, text): """Highlights the inputed text block based on the rules of this code highlighter""" + if not self.enabled(): + return + # Reset the highlight spans for this text block self.spans = [] @@ -131,7 +141,7 @@ def highlightText(self, text, expr, format): Args: text (str): text to highlight - expr (QRegularExpression): search parameter + expr (re.compile): search parameter format (QTextCharFormat): formatting rule """ if expr is None or not text: diff --git a/preditor/gui/console.py b/preditor/gui/console.py index 3e47c172..49322963 100644 --- a/preditor/gui/console.py +++ b/preditor/gui/console.py @@ -135,8 +135,7 @@ def __init__(self, parent): self.addAction(self.uiClearToLastPromptACT) self.x = 0 - self.clickPos = None - self.anchor = None + self.mousePressPos = None # Make sure console cursor is visible. It can get it's width set to 0 with # unusual(ie not 100%) os display scaling. @@ -220,30 +219,47 @@ def setConsoleFont(self, font): if origPercent is not None: self.doubleSingleShotSetScrollValue(origPercent) + def mouseMoveEvent(self, event): + """Overload of mousePressEvent to change mouse pointer to indicate it is + over a clickable error hyperlink. + """ + if self.anchorAt(event.pos()): + QApplication.setOverrideCursor(Qt.CursorShape.PointingHandCursor) + else: + QApplication.restoreOverrideCursor() + return super().mouseMoveEvent(event) + def mousePressEvent(self, event): """Overload of mousePressEvent to capture click position, so on release, we can check release position. If it's the same (ie user clicked vs click-drag to select text), we check if user clicked an error hyperlink. """ - self.clickPos = event.pos() - self.anchor = self.anchorAt(event.pos()) - if self.anchor: - QApplication.setOverrideCursor(Qt.CursorShape.PointingHandCursor) - return super(ConsolePrEdit, self).mousePressEvent(event) + left = event.button() == Qt.MouseButton.LeftButton + anchor = self.anchorAt(event.pos()) + self.mousePressPos = event.pos() + + if left and anchor: + event.ignore() + return + + return super().mousePressEvent(event) def mouseReleaseEvent(self, event): """Overload of mouseReleaseEvent to capture if user has left clicked... Check if click position is the same as release position, if so, call errorHyperlink. """ - samePos = event.pos() == self.clickPos + samePos = event.pos() == self.mousePressPos left = event.button() == Qt.MouseButton.LeftButton - if samePos and left and self.anchor: - self.errorHyperlink() + anchor = self.anchorAt(event.pos()) + + if samePos and left and anchor: + self.errorHyperlink(anchor) + self.mousePressPos = None - self.clickPos = None - self.anchor = None QApplication.restoreOverrideCursor() - return super(ConsolePrEdit, self).mouseReleaseEvent(event) + ret = super(ConsolePrEdit, self).mouseReleaseEvent(event) + + return ret def keyReleaseEvent(self, event): """Override of keyReleaseEvent to determine when to end navigation of @@ -254,7 +270,7 @@ def keyReleaseEvent(self, event): else: event.ignore() - def errorHyperlink(self): + def errorHyperlink(self, anchor): """Determine if chosen line is an error traceback file-info line, if so, parse the filepath and line number, and attempt to open the module file in the user's chosen text editor at the relevant line, using specified Command Prompt pattern. @@ -267,13 +283,13 @@ def errorHyperlink(self): doHyperlink = ( hasattr(window, 'uiErrorHyperlinksCHK') and window.uiErrorHyperlinksCHK.isChecked() - and self.anchor + and anchor ) if not doHyperlink: return # info is a comma separated string, in the form: "filename, workboxIdx, lineNum" - info = self.anchor.split(', ') + info = anchor.split(', ') modulePath = info[0] workboxName = info[1] lineNum = info[2] diff --git a/preditor/gui/find_files.py b/preditor/gui/find_files.py index 85ff80de..8fe1cafa 100644 --- a/preditor/gui/find_files.py +++ b/preditor/gui/find_files.py @@ -71,6 +71,11 @@ def find(self): self.finder.callback_matching = self.insert_found_text self.finder.callback_non_matching = self.insert_text + # Start fresh output line. + window = self.parent().window() if self.parent() else None + if window: + window.console().startPrompt("") + self.insert_text(self.finder.title()) self.match_files_count = 0 @@ -91,6 +96,11 @@ def find(self): ) ) + # If user has Auto-prompt chosen, do so now. + if window: + if window.uiAutoPromptCHK.isChecked(): + window.console().startInputLine() + def find_in_editor(self, editor, path): # Ensure the editor text is loaded and get its raw text editor.__show__() diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index d3608a6c..28d32c29 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -48,7 +48,7 @@ from ..gui.fuzzy_search.fuzzy_search import FuzzySearch from ..gui.group_tab_widget.grouped_tab_models import GroupTabListItemModel from ..logging_config import LoggingConfig -from ..utils import stylesheets +from ..utils import Json, stylesheets from .completer import CompleterMode from .level_buttons import LoggingLevelButton from .set_text_editor_path_dialog import SetTextEditorPathDialog @@ -98,9 +98,6 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiConsoleTXT.flash_window = self self.uiConsoleTXT.clearExecutionTime = self.clearExecutionTime self.uiConsoleTXT.reportExecutionTime = self.reportExecutionTime - self.uiClearToLastPromptACT.triggered.connect( - self.uiConsoleTXT.clearToLastPrompt - ) # If we don't disable this shortcut Qt won't respond to this classes or # the ConsolePrEdit's self.uiConsoleTXT.uiClearToLastPromptACT.setShortcut('') @@ -126,6 +123,7 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiLoggingLevelBTN, ) self.uiConsoleTOOLBAR.insertSeparator(self.uiRunSelectedACT) + self.uiConsoleTOOLBAR.show() # Configure Find in Workboxes self.uiFindInWorkboxesWGT.hide() @@ -137,22 +135,69 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self._stds = None self.uiLogToFileClearACT.setVisible(False) + # Call other setup methods + self.connectSignals() + self.createActions() + self.setIcons() + self.startFileSystemMonitor() + + self.maxRecentClosedWorkboxes = 20 + self.max_num_backups = 50 + self.dont_ask_again = [] + + # Load any plugins, and set window title + self.loadPlugins() + self.setWindowTitle(self.defineWindowTitle()) + + self.handleChangedUiElements() + + self.restorePrefs() + + self.setWorkboxFontBasedOnConsole() + self.setEditorChooserFontBasedOnConsole() + + self.setup_run_workbox() + + if not standalone: + # This action only is valid when running in standalone mode + self.uiRestartACT.setVisible(False) + + # Run the current workbox after the LoggerWindow is shown. + if run_workbox: + # By using two singleShot timers, we can show and draw the LoggerWindow, + # then call execAll. This makes it easier to see what code you are running + # before it has finished running completely. + # QTimer.singleShot(0, lambda: QTimer.singleShot(0, self.execAll)) + QTimer.singleShot( + 0, lambda: QTimer.singleShot(0, lambda: self.run_workbox(run_workbox)) + ) + + def connectSignals(self): + """Connect various signals""" + self.uiClearToLastPromptACT.triggered.connect( + self.uiConsoleTXT.clearToLastPrompt + ) + self.uiRestartACT.triggered.connect(self.restartLogger) self.uiCloseLoggerACT.triggered.connect(self.closeLoggerByAction) self.uiRunAllACT.triggered.connect(self.execAll) - # Even though the RunSelected actions (with shortcuts) are connected - # here, this only affects if the action is chosen from the menu. The - # shortcuts are always intercepted by the workbox document editor. To - # handle this, the workbox.keyPressEvent method will perceive the - # shortcut press, and call .execSelected, which will then ultimately call - # workbox.__exec_selected__ + # Even though the RunSelected and Open Most Recently Closed Workbox + # actions (with shortcuts) are connected here, this only affects if the + # action is chosen from the menu. The shortcuts are always intercepted + # by the workbox document editor. To handle this, the + # workbox.keyPressEvent method will perceive the shortcut press, and + # call the correct method. self.uiRunSelectedACT.triggered.connect( partial(self.execSelected, truncate=True) ) self.uiRunSelectedDontTruncateACT.triggered.connect( partial(self.execSelected, truncate=False) ) + # Closed workboxes + self.uiOpenMostRecentWorkboxACT.triggered.connect( + self.openMostRecentlyClosedWorkbox + ) self.uiConsoleAutoCompleteEnabledCHK.toggled.connect( partial(self.setAutoCompleteEnabled, console=True) @@ -189,28 +234,6 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): partial(self.adjustFontSize, "Gui", 1) ) - # Setup ability to cycle completer mode, and create action for each mode - self.completerModeCycle = itertools.cycle(CompleterMode) - # create CompleterMode submenu - defaultMode = next(self.completerModeCycle) - for mode in CompleterMode: - modeName = mode.displayName() - action = self.uiCompleterModeMENU.addAction(modeName) - action.setObjectName('ui{}ModeACT'.format(modeName)) - action.setData(mode) - action.setCheckable(True) - action.setChecked(mode == defaultMode) - completerMode = CompleterMode(mode) - action.setToolTip(completerMode.toolTip()) - action.triggered.connect(partial(self.selectCompleterMode, action)) - - self.uiCompleterModeMENU.addSeparator() - action = self.uiCompleterModeMENU.addAction('Cycle mode') - action.setObjectName('uiCycleModeACT') - action.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_M)) - action.triggered.connect(self.cycleCompleterMode) - self.uiCompleterModeMENU.hovered.connect(handleMenuHovered) - # Workbox add/remove self.uiNewWorkboxACT.triggered.connect( lambda: self.uiWorkboxTAB.add_new_tab(group=True) @@ -280,10 +303,12 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiClearBeforeRunningCHK.toggled.connect(self.setClearBeforeRunning) self.uiEditorVerticalCHK.toggled.connect(self.adjustWorkboxOrientation) self.uiEnvironmentVarsACT.triggered.connect(self.showEnvironmentVars) - self.uiBackupPreferencesACT.triggered.connect(self.backupPreferences) - self.uiBrowsePreferencesACT.triggered.connect(self.browsePreferences) self.uiAboutPreditorACT.triggered.connect(self.show_about) + # Prefs on disk + self.uiPrefsBrowseBTN.clicked.connect(self.browsePreferences) + self.uiPrefsBackupBTN.clicked.connect(self.backupPreferences) + self.uiSetPreferredTextEditorPathACT.triggered.connect( self.openSetPreferredTextEditorDialog ) @@ -294,6 +319,20 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): for menu in menus: menu.hovered.connect(handleMenuHovered) + # Scroll thru workbox versions + self.uiShowFirstWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.First) + ) + self.uiShowPreviousWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.Previous) + ) + self.uiShowNextWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.Next) + ) + self.uiShowLastWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.Last) + ) + # Preferences window self.uiClosePreferencesBTN.clicked.connect(self.update_workbox_stack) self.uiClosePreferencesBTN.clicked.connect(self.update_window_settings) @@ -301,6 +340,12 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): # Preferences self.uiExtraTooltipInfoCHK.toggled.connect(self.updateTabColorsAndToolTips) + # Code Highlighting + self.uiConsoleHighlightEnabledCHK.toggled.connect( + self.setConsoleHighlightEnabled + ) + + def setIcons(self): """Set various icons""" self.uiClearLogACT.setIcon(QIcon(resourcePath('img/close-thick.png'))) self.uiNewWorkboxACT.setIcon(QIcon(resourcePath('img/file-plus.png'))) @@ -312,21 +357,32 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiRestartACT.setIcon(QIcon(resourcePath('img/restart.svg'))) self.uiCloseLoggerACT.setIcon(QIcon(resourcePath('img/close-thick.png'))) - # Make action shortcuts available anywhere in the Logger + def createActions(self): + """Create the necessary actions""" self.addAction(self.uiClearLogACT) - self.dont_ask_again = [] - - # Load any plugins, and set window title - self.loadPlugins() - self.setWindowTitle(self.defineWindowTitle()) - - # Start the filesystem monitor - self.openFileMonitor = QFileSystemWatcher(self) - self.openFileMonitor.fileChanged.connect(self.linkedFileChanged) - self.setFileMonitoringEnabled(self.prefsPath(), True) + # Setup ability to cycle completer mode, and create action for each mode + self.completerModeCycle = itertools.cycle(CompleterMode) + # create CompleterMode submenu + defaultMode = next(self.completerModeCycle) + for mode in CompleterMode: + modeName = mode.displayName() + action = self.uiCompleterModeMENU.addAction(modeName) + action.setObjectName('ui{}ModeACT'.format(modeName)) + action.setData(mode) + action.setCheckable(True) + action.setChecked(mode == defaultMode) + completerMode = CompleterMode(mode) + action.setToolTip(completerMode.toolTip()) + action.triggered.connect(partial(self.selectCompleterMode, action)) - self.restorePrefs() + # Completer mode actions + self.uiCompleterModeMENU.addSeparator() + action = self.uiCompleterModeMENU.addAction('Cycle mode') + action.setObjectName('uiCycleModeACT') + action.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_M)) + action.triggered.connect(self.cycleCompleterMode) + self.uiCompleterModeMENU.hovered.connect(handleMenuHovered) # add stylesheet menu options. for style_name in stylesheets.stylesheets(): @@ -336,40 +392,11 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): action.setChecked(self._stylesheet == style_name) action.triggered.connect(partial(self.setStyleSheet, style_name)) - self.uiConsoleTOOLBAR.show() - - self.setWorkboxFontBasedOnConsole() - self.setEditorChooserFontBasedOnConsole() - - # Scroll thru workbox versions - self.uiShowFirstWorkboxVersionACT.triggered.connect( - partial(self.change_to_workbox_version_text, prefs.VersionTypes.First) - ) - self.uiShowPreviousWorkboxVersionACT.triggered.connect( - partial(self.change_to_workbox_version_text, prefs.VersionTypes.Previous) - ) - self.uiShowNextWorkboxVersionACT.triggered.connect( - partial(self.change_to_workbox_version_text, prefs.VersionTypes.Next) - ) - self.uiShowLastWorkboxVersionACT.triggered.connect( - partial(self.change_to_workbox_version_text, prefs.VersionTypes.Last) - ) - - self.setup_run_workbox() - - if not standalone: - # This action only is valid when running in standalone mode - self.uiRestartACT.setVisible(False) - - # Run the current workbox after the LoggerWindow is shown. - if run_workbox: - # By using two singleShot timers, we can show and draw the LoggerWindow, - # then call execAll. This makes it easier to see what code you are running - # before it has finished running completely. - # QTimer.singleShot(0, lambda: QTimer.singleShot(0, self.execAll)) - QTimer.singleShot( - 0, lambda: QTimer.singleShot(0, lambda: self.run_workbox(run_workbox)) - ) + def startFileSystemMonitor(self): + """Start the file system monitor, and add this PrEditor's prefs path""" + self.openFileMonitor = QFileSystemWatcher(self) + self.openFileMonitor.fileChanged.connect(self.linkedFileChanged) + self.setFileMonitoringEnabled(self.prefsPath(), True) @Slot() def apply_options(self): @@ -431,6 +458,18 @@ def loadPlugins(self): if name not in self.plugins: self.plugins[name] = plugin(self) + def handleChangedUiElements(self): + """To prevent errors if user has newer PrEditor, but older plugins, + we keep the ui elements until the ui and plugins have been loaded. Now + we can check if now deprecated can safely be deleted. + """ + + # Preferences are moved to a tab page, so this menu should be removed. + # But it may be used by some plugins, so only remove it if the plugins + # have also been updated. + if self.uiPreferencesMENU.isEmpty(): + self.uiPreferencesMENU.deleteLater() + def defineWindowTitle(self): """Define the window title, including and info plugins may add.""" @@ -957,6 +996,9 @@ def browsePreferences(self): def console(self): return self.uiConsoleTXT + def setConsoleHighlightEnabled(self, state): + self.console().codeHighlighter().setEnabled(state) + def clearLog(self): self.uiConsoleTXT.clear() @@ -1290,11 +1332,13 @@ def clearExecutionTime(self): """Update status text with hyphens to indicate execution has begun.""" self.setStatusText('Exec: -.- Seconds') QApplication.instance().processEvents() + self.statusTimer.stop() def reportExecutionTime(self, seconds): """Update status text with seconds passed in.""" self.uiStatusLBL.showSeconds(seconds) self.uiMenuBar.adjustSize() + self.statusTimer.stop() def recordPrefs(self, manual=False, disableFileMonitoring=False): if not manual and not self.autoSaveEnabled(): @@ -1356,6 +1400,9 @@ def recordPrefs(self, manual=False, disableFileMonitoring=False): 'closedWorkboxData': self.getClosedWorkboxData(), 'confirmBeforeClose': self.uiConfirmBeforeCloseCHK.isChecked(), 'displayExtraTooltipInfo': self.uiExtraTooltipInfoCHK.isChecked(), + 'consoleHighlightEnabled': ( + self.uiConsoleHighlightEnabledCHK.isChecked() + ), } ) @@ -1423,9 +1470,35 @@ def load_prefs(self): prefs_dict = {} self.auto_backup_prefs(filename, onlyFirst=True) - if os.path.exists(filename): - with open(filename) as fp: - prefs_dict = json.load(fp) + filename = Path(filename) + if filename.exists(): + try: + prefs_dict = Json(filename).load() + except ValueError as error: + # If there is a problem with the preferences ask the user if they + # want to reset them. Depending on the problem the loaded workbox's + # have likely already losing the tab information, but this does + # allow the user to try to debug the file instead of just resetting + # preferences. The .py files likely still exist but won't have names. + msg = ( # noqa: E702, E231 + "The following error happened while restoring PrEditor prefs:", + f'
{error}
', + "This can be resolved by resetting the prefs. Do you want " + "to do it?", + ) + box = QMessageBox() + box.setIcon(QMessageBox.Icon.Question) + box.setWindowTitle("Reset Corrupted Preferences?") + box.setTextFormat(Qt.TextFormat.RichText) + box.setText("