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("
".join(msg)) + box.addButton(QMessageBox.StandardButton.Yes) + box.addButton(QMessageBox.StandardButton.No) + if box.exec() == QMessageBox.StandardButton.Yes: + prefs_dict = {} + with filename.open("w") as fp: + json.dump(prefs_dict, fp, indent=4, sort_keys=True) + else: + raise return prefs_dict @@ -1605,7 +1678,7 @@ def restorePrefs(self, skip_geom=False): self.uiFindInWorkboxesWGT.uiFindTXT.setText(pref.get('find_files_text', '')) # External text editor filepath and command template - defaultExePath = r"C:\Program Files\Sublime Text 3\sublime_text.exe" + defaultExePath = r"C:\Program Files\Sublime Text\sublime_text.exe" defaultCmd = r'"{exePath}" "{modulePath}":{lineNum}' self.textEditorPath = pref.get('textEditorPath', defaultExePath) self.textEditorCmdTempl = pref.get('textEditorCmdTempl', defaultCmd) @@ -1631,6 +1704,10 @@ def restorePrefs(self, skip_geom=False): self.uiWorkboxAutoCompleteEnabledCHK.setChecked(workboxHintingEnabled) self.setAutoCompleteEnabled(workboxHintingEnabled, console=False) + self.uiConsoleHighlightEnabledCHK.setChecked( + pref.get('consoleHighlightEnabled', True) + ) + # Max backups and recently closed workboxes max_recent_workboxes = pref.get('max_recent_workboxes', 25) self.uiMaxNumRecentWorkboxesSPIN.setValue(max_recent_workboxes) @@ -1815,13 +1892,22 @@ def getClosedWorkboxData(self): data.append(datum) return data - def recentWorkboxActionTriggered(self): + def recentWorkboxActionTriggered(self, checked=None, action=None): """Slot for when user selects a Recently Closed Workbox. First, try to just show the workbox if it's currently open. If not, recreate it. In both cases, set focus on that workbox. + Args: + checked (bool, optional): If this is method is called as slot, the + arg 'checked' is automatically passed + action (QAction, optional): If this method is called by + openMostRecentlyClosedWorkbox, this is the determined most recent + workbox action. + """ - action = self.sender() + if action is None: + action = self.sender() + workboxDatum = action.data() workbox_id = workboxDatum.get("workbox_id") workbox_filename = workboxDatum.get("filename") @@ -1861,6 +1947,16 @@ def recentWorkboxActionTriggered(self): workbox.__tab_widget__().tabBar().updateColorsAndToolTips() + if workbox is not None: + workbox.__tab_widget__().tabBar().updateColorsAndToolTips() + + def openMostRecentlyClosedWorkbox(self): + """Restore the most recently closed workbox""" + actions = self.uiClosedWorkboxesMENU.actions() + if actions: + action = actions[0] + self.recentWorkboxActionTriggered(action=action) + def setAutoCompleteEnabled(self, state, console=True): if console: self.uiConsoleTXT.completer().setEnabled(state) diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index 5b8fb53e..e714b1bb 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -7,7 +7,7 @@ 0 0 899 - 684 + 766 @@ -39,7 +39,7 @@ QFrame::NoFrame - 0 + 2 @@ -65,8 +65,8 @@ 0 0 - 879 - 379 + 156 + 29 @@ -146,6 +146,9 @@ + + + true @@ -155,40 +158,37 @@ 0 0 879 - 379 + 420 - + + + + 0 + 0 + + - - Preferences - - - - 3 - - - 3 - - - 3 - - - 3 - - - 3 - + - + + + + 0 + 0 + + - + + Preferences + + 3 @@ -199,377 +199,23 @@ 3 - 3 + 30 - 3 + 30 - - - - - - - - 3 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - General - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Auto-prompt - - - - - - - Confirm before closing with Ctrl+Q action - - - true - - - - - - - Console Word Wrap - - - - - - - Convert tabs to spaces on copy - - - - - - - Indent using tabs - - - - - - - Spell check - - - - - - - Vertical editor - - - - - - - - - - Files - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Auto save on close - - - - - - - If a linked file has been deleted or modified, ask how to handle. - - - Prompt for linked file modification / deletion - - - true - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - 3 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Auto-Completion - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Auto-Complete in console - - - - - - - Auto-Complete in workbox - - - - - - - Highligh exact completion - - - - - - - - - - Clear - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Clear console before running workbox code - - - - - - - - - - Tracebacks - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Error hyperlinks - - - - - - - Visually separate internal PrEditor traceback - - - - - - - - - - Internal Debug - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Display extra workbox info in tooltips - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - - - 3 - - - 3 - - - 3 - - - 3 - - - 3 - + - - - Numeric Settings + + + - + 3 @@ -586,15 +232,11 @@ 3 - - - Set the maximun number of backup files on disk per workbox. -Must be at least 1 - + - + 3 @@ -602,74 +244,190 @@ Must be at least 1 3 - 0 + 3 3 - 0 + 3 - - - Qt::Horizontal - - - - 40 - 20 - + + + General - + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + After running workbox code, start a new prompt (>>> ) + + + Auto-prompt + + + + + + + Confirm before closing with Ctrl+Q action + + + true + + + + + + + Console Word Wrap + + + + + + + Convert tabs to spaces on copy + + + + + + + Indent using tabs + + + + + + + Spell check + + + + + + + Vertical editor + + + + + - - - Set the maximun number of backup files on disk per workbox. -Must be at least 1 - - - Max recently closed workboxes + + + Files + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Auto save on close + + + + + + + If a linked file has been deleted or modified, ask how to handle. + + + Prompt for linked file modification / deletion + + + true + + + + - - - - 0 - 0 - + + + Clear - - Set the maximun number of backup files on disk per workbox. -Must be at least 1 - - - 1 - - - 999 + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Clear console before running workbox code + + + + + + + + + + Qt::Vertical - - 25 + + + 20 + 0 + - + - - - Set the maximun number of backup files on disk per workbox. -Must be at least 1 - + - + 3 @@ -677,66 +435,184 @@ Must be at least 1 3 - 0 + 3 3 - 0 + 3 - - - Qt::Horizontal - - - - 40 - 20 - + + + Auto-Completion - + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Auto-Complete in console + + + + + + + Auto-Complete in workbox + + + + + + + Highligh exact completion + + + + + - - - Set the maximun number of backup files on disk per workbox. -Must be at least 1 + + + Code Highlighting - - Max number of Backups + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Code highlighting in console + + + true + + + + + + + + + + Tracebacks + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Error hyperlinks + + + + + + + Visually separate internal PrEditor traceback + + + + - - - Set the maximun number of backup files on disk per workbox. -Must be at least 1 + + + Internal Debug - - 1 + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Display extra workbox info in tooltips + + + + + + + + + + Qt::Vertical - - 999 + + + 20 + 0 + - + - - - 'If running code in the logger takes X seconds or longer, - the window will flash if it is not in focus. - Setting the value to zero will disable flashing.' - + - + 3 @@ -744,67 +620,376 @@ Must be at least 1 3 - 0 + 3 3 - 0 + 3 - - - Qt::Horizontal - - - - 40 - 20 - + + + Numeric Settings - - - - - - 'If running code in the logger takes X seconds or longer, + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Set the maximun number of backup files on disk per workbox. +Must be at least 1 + + + + + + + 3 + + + 3 + + + 0 + + + 3 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + Set the maximun number of backup files on disk per workbox. +Must be at least 1 + + + Max recently closed workboxes + + + + + + + + 0 + 0 + + + + Set the maximun number of backup files on disk per workbox. +Must be at least 1 + + + 1 + + + 999 + + + 25 + + + + + + + + + + Set the maximun number of backup files on disk per workbox. +Must be at least 1 + + + + + + + 3 + + + 3 + + + 0 + + + 3 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + Set the maximun number of backup files on disk per workbox. +Must be at least 1 + + + Max number of Backups + + + + + + + Set the maximun number of backup files on disk per workbox. +Must be at least 1 + + + 1 + + + 999 + + + + + + + + + + 'If running code in the logger takes X seconds or longer, the window will flash if it is not in focus. Setting the value to zero will disable flashing.' - - - Flash Interval - + + + + + + + 3 + + + 3 + + + 0 + + + 3 + + + 0 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + 'If running code in the logger takes X seconds or longer, + the window will flash if it is not in focus. + Setting the value to zero will disable flashing.' + + + Flash Interval + + + + + + + 'If running code in the logger takes X seconds or longer, + the window will flash if it is not in focus. + Setting the value to zero will disable flashing.' + + + + + + + - - - 'If running code in the logger takes X seconds or longer, - the window will flash if it is not in focus. - Setting the value to zero will disable flashing.' + + + Prefs files on disk + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + + + 6 + + + 6 + + + 0 + + + 0 + + + 3 + + + + + + 1 + 0 + + + + Browse + + + + + + + + 1 + 0 + + + + This may take a long time. + + + Backup + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + - - - - Qt::Vertical - - - - 20 - 40 - - - - + + + + QDialogButtonBox::Close + + + @@ -812,15 +997,34 @@ Must be at least 1 - - - QDialogButtonBox::Close + + + Qt::Horizontal - + + + 57 + 20 + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + @@ -870,6 +1074,7 @@ Must be at least 1 + @@ -882,8 +1087,6 @@ Must be at least 1 Preferences - - @@ -1145,23 +1348,6 @@ Must be at least 1 Ctrl+Alt+Shift+D - - - true - - - false - - - Use Auto-Complete in console - - - Use Auto-Complete in console - - - Use Auto-Complete in console - - Run Line... @@ -1197,25 +1383,6 @@ Must be at least 1 Shift+Return - - - true - - - Indentations use tabs - - - - - true - - - Enable Console Word Wrap - - - Enable word wrap in the python output console - - Save Console Settings @@ -1227,17 +1394,6 @@ Must be at least 1 Ctrl+S - - - true - - - Clear before running workbox code - - - Clear console log before running workbox code. - - Close @@ -1249,17 +1405,6 @@ Must be at least 1 Ctrl+Q - - - true - - - Editor Vertical - - - Editor Vertical - - Clear to Last Prompt @@ -1271,14 +1416,6 @@ Must be at least 1 Ctrl+Shift+Backspace - - - true - - - Convert Tabs to Spaces on Copy - - Reset Warning Filters @@ -1305,14 +1442,6 @@ Must be at least 1 If executing code takes longer than this many seconds, flash the main window of the application. - - - true - - - Use Spell-Check - - Log Output to File @@ -1346,14 +1475,6 @@ Must be at least 1 Clear Output File - - - Browse... - - - Browse Preferences - - true @@ -1400,22 +1521,6 @@ Must be at least 1 Ctrl+M - - - true - - - true - - - Auto-Save Console Settings - - - Auto-Save Console Settings and Workbox code. -Useful if often using multiple DCC instances simultaneously. -Must manually save instead. - - New Workbox @@ -1526,14 +1631,6 @@ Must manually save instead. Ctrl+Shift+Tab - - - true - - - Auto-prompt - - true @@ -1547,19 +1644,6 @@ Must manually save instead. Set Preferred Text Editor Path - - - true - - - Error Hyperlinks - - - Show Error Hyperlinks, which can be clicked to open the indicated module -at the indicated line in the specified text editor. - - - Select Current Line @@ -1666,11 +1750,6 @@ at the indicated line in the specified text editor. Ctrl+Alt+9 - - - Backup - - Focus To Name @@ -1698,14 +1777,6 @@ at the indicated line in the specified text editor. Ctrl+Shift+F - - - true - - - Use Auto-Complete in workbox - - Choose from monospace fonts @@ -1738,14 +1809,6 @@ at the indicated line in the specified text editor. Ctrl+Shift+Return - - - true - - - Highlight Exact Completion - - Show First Workbox Version @@ -1827,14 +1890,6 @@ at the indicated line in the specified text editor. Ctrl+Alt+- - - - true - - - Visually Separate PrEditor Traceback - - Run First Workbox @@ -1855,6 +1910,20 @@ at the indicated line in the specified text editor. Empty Workbox Recycle Bin + + Once a workbox is pruned off the Recently Closed Workboxes list +(as determiend by Max Recently Closed Workboxes preference), it +goes to the Workbox Recycle Bin. +This button removes those (very old) workboxes. + + + + + Open most recently closed Workbox + + + Ctrl+Shift+T + diff --git a/preditor/gui/workbox_mixin.py b/preditor/gui/workbox_mixin.py index ff6e1a8d..dea1024e 100644 --- a/preditor/gui/workbox_mixin.py +++ b/preditor/gui/workbox_mixin.py @@ -245,12 +245,12 @@ def __exec_all__(self): def __exec_selected__(self, truncate=True): txt, lineNum = self.__selected_text__() - # Remove any leading white space shared across all lines - txt = textwrap.dedent(txt) - # Get rid of pesky \r's txt = self.__unix_end_lines__(txt) + # Remove any leading white space shared across all lines + txt = textwrap.dedent(txt) + # Make workbox line numbers match the workbox line numbers, by adding # the appropriate number of newlines to mimic it's original position in # the workbox. @@ -1130,6 +1130,9 @@ def process_shortcut(self, event, run=True): evalTrunc = enter or (ret and shift) evalNoTrunc = ret and ctrlShift + # See if shortcut for Open Most Recent Workbox is pressed + openRecentWorkbox = ctrlShift and key == Qt.Key.Key_T + if evalTrunc: # Execute with truncation self.window().execSelected() @@ -1137,6 +1140,9 @@ def process_shortcut(self, event, run=True): # Execute without truncation self.window().execSelected(truncate=False) + elif openRecentWorkbox: + self.window().openMostRecentlyClosedWorkbox() + if evalTrunc or evalNoTrunc: if self.window().uiAutoPromptCHK.isChecked(): self.__console__().startInputLine() diff --git a/preditor/logging_config.py b/preditor/logging_config.py index d6239b4f..76e3cbb2 100644 --- a/preditor/logging_config.py +++ b/preditor/logging_config.py @@ -5,6 +5,7 @@ import logging.config import os +from . import utils from .prefs import prefs_path @@ -45,9 +46,8 @@ def load(self): if not os.path.exists(self.filename): return False - with open(self.filename) as fle: - self.cfg = json.load(fle) - logging.config.dictConfig(self.cfg) + self.cfg = utils.Json(self.filename).load() + logging.config.dictConfig(self.cfg) return True def save(self): diff --git a/preditor/prefs.py b/preditor/prefs.py index c4843932..16f4ff68 100644 --- a/preditor/prefs.py +++ b/preditor/prefs.py @@ -14,7 +14,7 @@ import six -from . import resourcePath +from . import resourcePath, utils # cache of all the preferences _cache = {} @@ -297,11 +297,8 @@ def get_prefs_updates(): """ updates = {} path = resourcePath(r"pref_updates\pref_updates.json") - with open(path, 'r') as f: - updates = json.load(f) try: - with open(path, 'r') as f: - updates = json.load(f) + updates = utils.Json(path).load() except (FileNotFoundError, json.decoder.JSONDecodeError): pass return updates diff --git a/preditor/scintilla/documenteditor.py b/preditor/scintilla/documenteditor.py index a586af84..09c0351d 100644 --- a/preditor/scintilla/documenteditor.py +++ b/preditor/scintilla/documenteditor.py @@ -1565,9 +1565,12 @@ def updateFilename(self, filename): if self._filename and ( filename and extension != os.path.splitext(self._filename)[1] ): - self.setLanguage(lang.byExtension(extension)) + language = lang.byExtension(extension) else: - self.setLanguage(self._defaultLanguage) + language = self._defaultLanguage + + if language != self.language(): + self.setLanguage(language) # update the filename information filename = os.path.abspath(filename) if filename else "" diff --git a/preditor/utils/__init__.py b/preditor/utils/__init__.py index e69de29b..545dcfc5 100644 --- a/preditor/utils/__init__.py +++ b/preditor/utils/__init__.py @@ -0,0 +1,99 @@ +import errno +import json +import os +import sys +from pathlib import Path + + +class Json: + """Load a json file with a better tracebacks if something goes wrong. + + Args: + filename (pathlib.Path): The path to the file being loaded/parsed. Unless + `json_str` is also provided load will use `json.load` to parse the + contents of this json file. + json_str (str, optional): If provided then uses `json.loads` to parse + this value. `filename` must be provided and will be included in any + exceptions that are raised parsing this text. + """ + + def __init__(self, filename, json_str=None): + if isinstance(filename, str): + filename = Path(filename) + self.filename = filename + self.json_str = json_str + + @classmethod + def _load_json(cls, source, load_funct, *args, **kwargs): + """Work function that parses json and ensures any errors report the source. + + Args: + source (os.PathLike or str): The source of the json data. This is + reported in any raised exceptions. + load_funct (callable): A function called to parse the json data. + Normally this is `json.load` or `json.loads`. + *args: Arguments passed to `load_funct`. + *kwargs: Keyword arguments passed to `load_funct`. + + Raises: + FileNotFoundError: If filename is not pointing to a file that + actually exists. + ValueError: The error raised due to invalid json. + """ + try: + return load_funct(*args, **kwargs) + except ValueError as e: + # Using python's native json parser + msg = f'{e} Source("{source}")' + raise type(e)(msg, e.doc, e.pos).with_traceback(sys.exc_info()[2]) from None + + def load(self): + """Parse and return self.json_str if defined otherwise self.filename.""" + if self.json_str: + return self.loads_json(self.json_str, self.filename) + return self.load_json_file(self.filename) + + @classmethod + def load_json_file(cls, filename): + """Open and parse a json file. If a parsing error happens the file path + is added to the exception to allow for easier debugging. + + Args: + filename (pathlib.Path): A existing file path. + + Returns: + The data stored in the json file. + + Raises: + FileNotFoundError: If filename is not pointing to a file that + actually exists. + ValueError: The error raised due to invalid json. + """ + if not filename.is_file(): + raise FileNotFoundError( + errno.ENOENT, os.strerror(errno.ENOENT), str(filename) + ) + + with filename.open() as fle: + data = cls._load_json(filename, json.load, fle) + return data + + @classmethod + def loads_json(cls, json_str, source): + """Open and parse a json string. If a parsing error happens the source + file path is added to the exception to allow for easier debugging. + + Args: + json_str (str): The json data to parse. + source (pathlib.Path): The location json_str was pulled from. + This is reported if any parsing errors happen. + + Returns: + The data stored in the json file. + + Raises: + FileNotFoundError: If filename is not pointing to a file that + actually exists. + ValueError: The error raised due to invalid json. + """ + return cls._load_json(source, json.loads, json_str)