From 194c0db1f1468b5ef8de3a59c53cde1dc2060e13 Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Mon, 13 Jul 2026 09:35:45 -0700 Subject: [PATCH 1/3] Add shortcut to toggle Preferences --- preditor/gui/loggerwindow.py | 11 +++++++++-- preditor/gui/ui/loggerwindow.ui | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 80abd8d3..24f7a9a2 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -2191,8 +2191,15 @@ def show_workbox_options(self): self.uiWorkboxSTACK.setCurrentIndex(WorkboxPages.Options) @Slot() - def show_preferences(self): - self.uiWorkboxSTACK.setCurrentIndex(WorkboxPages.Preferences) + def toggle_preferences(self): + cur_idx = self.uiWorkboxSTACK.currentIndex() + + if cur_idx == WorkboxPages.Preferences: + target_idx = WorkboxPages.Workboxes + else: + target_idx = WorkboxPages.Preferences + + self.uiWorkboxSTACK.setCurrentIndex(target_idx) @Slot() def show_find_in_workboxes(self): diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index 367d8ed5..7443a97e 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -1768,7 +1768,10 @@ Must be at least 1 - Preferences + Toggle Preferences + + + Ctrl+Alt+P @@ -1853,7 +1856,7 @@ This button removes those (very old) workboxes. uiPreferencesACT triggered() PrEditorWindow - show_preferences() + toggle_preferences() -1 From e4533df924aa1f8f20c897bc5ac93f2e16a1ad3b Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Mon, 13 Jul 2026 19:31:18 -0700 Subject: [PATCH 2/3] Allow user to select text within an error hyperlink --- preditor/gui/console_base.py | 73 +++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/preditor/gui/console_base.py b/preditor/gui/console_base.py index 60e25866..16457fce 100644 --- a/preditor/gui/console_base.py +++ b/preditor/gui/console_base.py @@ -63,10 +63,15 @@ def __init__(self, parent: QWidget, controller: Optional[LoggerWindow] = None): self.addSepNewline = False self.consoleLine = None self.mousePressPos = None + self.mouseReleasePos = None + self.mouseReleaseBtn = None + self.logging_info = {} self.init_actions() + self.initDoubleClickTimer() + def __repr__(self): """The repr for this object including its objectName if set.""" name = self.objectName() @@ -119,6 +124,16 @@ def __defineRegexPatterns(cls): pattern = r'File "(?P.*)", line (?P\d{1,10})(, in|\r\n|\n|$)' cls.traceback_pattern = re.compile(pattern) + def initDoubleClickTimer(self): + """Initialize a timer to determine whether a single-click or + double-click has occured. + """ + self.clickTimer = QTimer(self) + self.clickTimer.setSingleShot(True) + self.clickTimer.timeout.connect(self.handleSingleClick) + self.clickTimer.setInterval(QApplication.instance().doubleClickInterval()) + self.doubleClickActive = False + def add_separator(self): """Add a marker line for visual separation of console output.""" # Ensure the input is written to the end of the document on a new line @@ -443,32 +458,62 @@ def mousePressEvent(self, event): 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. """ - 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. + """Overload of mouseReleaseEvent to determine if this release is from a + double click. If so, deactivate doubleClickActive. If not, capture + event.pos and event.button for handleSingleClick to use. + """ + if self.doubleClickActive: + # This release belongs to the second click of a double-click. + # Ignore it so it doesn't restart the single-click timer. + self.doubleClickActive = False + else: + # Capture event info to be used later. + self.mouseReleasePos = event.pos() + self.mouseReleaseBtn = event.button() + + QApplication.restoreOverrideCursor() + self.clickTimer.start() + + ret = super().mouseReleaseEvent(event) + return ret + + def mouseDoubleClickEvent(self, event): + """Overload mouseDoubleClickEvent so we can stop click_timer, and + indicate a double click is active, so we can later ignore it's release + + Args: + event (QEvent): The current mouse event """ - samePos = event.pos() == self.mousePressPos - left = event.button() == Qt.MouseButton.LeftButton - anchor = self.anchorAt(event.pos()) + # Cancel any pending single-click + self.clickTimer.stop() + + # Flag that a release is coming, and should be ignored + self.doubleClickActive = True + + super().mouseDoubleClickEvent(event) + def handleSingleClick(self): + """Slot for clickTimer.timeout, which means it wasn't a double-click. + We can now check if mouse pointer hasn't moved between click and release, + and if so, attempt to process an errorHyperlink. + """ + samePos = self.mouseReleasePos == self.mousePressPos + left = self.mouseReleaseBtn == Qt.MouseButton.LeftButton + anchor = self.anchorAt(self.mouseReleasePos) if samePos and left and anchor: self.errorHyperlink(anchor) + + # Reset mouse-event-releated variables self.mousePressPos = None + self.mouseReleasePos = None + self.mouseReleaseBtn = None QApplication.restoreOverrideCursor() - ret = super().mouseReleaseEvent(event) - - return ret @classmethod def parseErrorHyperLinkInfo(cls, txt): From e4608b09410957768c6762af2773c0f78aebdae7 Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Thu, 9 Jul 2026 17:24:39 -0700 Subject: [PATCH 3/3] New internal traceback lines features - Make highlighting internal code hyperlinks optional - Make displaying internal code hyperlinks optional Also, make msgs which are sent to _write more consistently formatted - Combine piecemeal messages into single lines - Create a list of consistently formatted lines to iterate over. This fixes an issue with how newer excepthooks issue their tracebacks which prevented hyperlinks and internal-code related features from working. --- preditor/gui/console_base.py | 262 ++++++++++++++++++++++---------- preditor/gui/loggerwindow.py | 14 +- preditor/gui/ui/loggerwindow.ui | 18 ++- 3 files changed, 211 insertions(+), 83 deletions(-) diff --git a/preditor/gui/console_base.py b/preditor/gui/console_base.py index 16457fce..95f97a8e 100644 --- a/preditor/gui/console_base.py +++ b/preditor/gui/console_base.py @@ -60,12 +60,23 @@ def __init__(self, parent: QWidget, controller: Optional[LoggerWindow] = None): highlight = CodeHighlighter(self, 'Python') self.setCodeHighlighter(highlight) + # Traceback handling variables + self.inInternalTraceLines = False self.addSepNewline = False self.consoleLine = None self.mousePressPos = None self.mouseReleasePos = None self.mouseReleaseBtn = None + self.internalTraceStart = r' +File .*preditor.*, line \d{1,6}, in keyPressEvent' + self.internalTraceStart = re.compile(self.internalTraceStart) + self.internalTraceEnd = ("cmdresult = eval", "exec(compiled,") + self.traceIgnores = ["sys.argv[0] = sys.argv[0].removesuffix('.exe')"] + + # Variables to handle how different excepthooks may deliver traceback msgs + self.lineParts = [] + self.constructedLine = None + self.logging_info = {} self.init_actions() @@ -210,11 +221,7 @@ def errorHyperlink(self, anchor): # Bail if there isn't a controller return # Bail if Error Hyperlinks setting is not turned on or we don't have an anchor. - doHyperlink = ( - self.controller - and self.controller.uiErrorHyperlinksCHK.isChecked() - and anchor - ) + doHyperlink = self.errorHyperLinksChoice and anchor if not doHyperlink: return @@ -300,6 +307,10 @@ def getIndentForCodeTracebackLine(cls, msg): indent (str): A string of zero or more spaces used for indentation """ indent = "" + + if msg.startswith("\n"): + msg = msg[1:] + match = re.match(r"^ *", msg) if match: indent = match.group() * 2 @@ -674,6 +685,30 @@ def get_logging_info(self, name): # Otherwise ignore it return None + def _controllerSetting(self, name): + value = False + if self.controller: + widget = getattr(self.controller, name, None) + if widget and hasattr(widget, "isChecked"): + value = widget.isChecked() + return value + + @property + def separateInternalTraceChoice(self): + return self._controllerSetting("uiSeparateTracebackCHK") + + @property + def errorHyperLinksChoice(self): + return self._controllerSetting("uiErrorHyperlinksCHK") + + @property + def hideIntTraceChoice(self): + return self._controllerSetting("uiHideInternalTracebackCHK") + + @property + def uiInhibitInternalHyperlinksChoice(self): + return self._controllerSetting("uiInhibitInternalLinksCHK") + def write_error(self, *exc_info): text = traceback.format_exception(*exc_info) for line in text: @@ -709,31 +744,114 @@ def write_log(self, log_data, stream_type=StreamType.CONSOLE): self.write(f'{msg}\n', stream_type=stream_type) def write(self, msg, stream_type=StreamType.STDOUT): - """Write a message to the logger. + """ + Override QTextEdit.write method. + + First, we handle the case where the excepthook delivers the some lines of + a traceback in a piecemeal manner. When this happens, capture all the + pieces, and construct a single line. So, if we receive: + msg1: " " + msg2: "some line of code" + msg3: "\n" + it is transformed into: + " some line of code\n" + By doing so, we can process the lines downstream in a consistent manner. + + Once it's constructed, or if msg is already fully composed (ie ends with a + newline character), it is issued downstream to _write_prep + + Args: + msg (str): The received msg to output to this console + stream_type (bool, optional): Treat this write as as stderr output. + """ + # Collect msgs which don't until with newline, until we reach one that does. + if not msg.endswith("\n"): + self.lineParts.append(msg) + return + + # If the current msg ends with newline (or is newline), and we have some + # self.lineParts, construct the line, and issue it to _write_prep. + if self.lineParts and msg.endswith("\n"): + self.constructedLine = "".join(self.lineParts) + msg + self._write_prep(self.constructedLine, stream_type=stream_type) + + # Reset variables + self.lineParts = [] + self.constructedLine = None + return + + # If it's a normal line, issue it to _write_prep + self._write_prep(msg, stream_type=stream_type) + + def _write_prep(self, msg, stream_type=StreamType.STDOUT): + """Prepare to output to this console. We handle various things as: + - Receiving a traceback in a single message, or multiple messages. + - Determining if we are processing internal code-lines of a traceback + - Adding a separator between internal and user code in a traceback. + - Skipping certain irrelevent lines a given excepthook may issue Args: msg (str): The message to write. stream_type (bool, optional): Treat this write as as stderr output. - In order to make a stack-trace provide clickable hyperlinks, it must be sent - to self._write line-by-line, like a actual exception traceback is. So, we check - if msg has the stack marker str, if so, send it line by line, otherwise, just - pass msg on to self._write. + Depending on the installed excepthook, stack-traces and/or exception + tracebacks may be issued in a single msg, or in multipe messages. In + order to make a stack-trace provide clickable hyperlinks, it must be + sent to self._write line-by-line. So, in either case, we construct a + uniform list of lines, and iterate them to be processed and send to the + _write method. + + Also, some excepthooks may include extra internal lines we don't care, + about, so we use the list 'self.traceIgnores' to remove them. """ - stack_marker = "Stack (most recent call last)" - index = msg.find(stack_marker) - has_stack_marker = index > -1 - - if has_stack_marker: - lines = msg.split("\n") - for line in lines: - line = "{}\n".format(line) - self._write(line, stream_type=stream_type) - else: - self._write(msg, stream_type=stream_type) + # Make sure we have a consistent list of lines to issue to _write + lines = msg.rstrip().split("\n") + lines = [f"{line}\n" for line in lines] + + for line in lines: + # Skip irrelevant lines which may have been issued by the excepthook + if line.strip() in self.traceIgnores: + continue + + # Determine if we are starting to issue internal traceback lines + match = self.internalTraceStart.match(line) + if match: + self.inInternalTraceLines = True + + # To make it easier to see relevant lines of a traceback, optionally + # insert a newline separating internal PrEditor code from the code + # run by user. + if self.addSepNewline: + if self.separateInternalTraceChoice: + line = f"\n{line}" + self.addSepNewline = False + + # Now write the line + self._write(line, stream_type=stream_type) + + # Handle when we reach the end of the internal part of a traceback. + # Only add the optional internal/user line separator only if not + # already hiding the internal lines. + if line.strip().startswith(self.internalTraceEnd): + self.inInternalTraceLines = False + if not self.hideIntTraceChoice: + self.addSepNewline = True def _write(self, msg, stream_type=StreamType.STDOUT): - """write the message to the logger""" + """write the message to the logger, handling the presentation ie text + color formatting, hyperlinks, etc. + + Args: + msg (str): The message to write. + stream_type (bool, optional): Treat this write as as stderr output. + """ + + # Handle internal traceback lines + hideInternalTrace = self.hideIntTraceChoice + inhibitInternalHyperlinks = self.uiInhibitInternalHyperlinksChoice + if hideInternalTrace and self.inInternalTraceLines and not msg: + return + if not msg: return @@ -758,12 +876,6 @@ def _write(self, msg, stream_type=StreamType.STDOUT): if not to_error and not self.stream_echo_stdout: return - if self.controller: - doHyperlink = self.controller.uiErrorHyperlinksCHK.isChecked() - sepPreditorTrace = self.controller.uiSeparateTracebackCHK.isChecked() - else: - doHyperlink = False - sepPreditorTrace = False self.moveCursor(QTextCursor.MoveOperation.End) charFormat = QTextCharFormat() @@ -782,7 +894,7 @@ def _write(self, msg, stream_type=StreamType.STDOUT): cursor = self.textCursor() info = None - if doHyperlink and msg == '\n': + if self.errorHyperLinksChoice and msg == '\n': cursor.select(QTextCursor.SelectionType.BlockUnderCursor) line = cursor.selectedText() @@ -827,7 +939,7 @@ def _write(self, msg, stream_type=StreamType.STDOUT): # They don't include ", in ..." and are issued differently than # other Exceptions, in that they will issue the final piece of # offending code, whereas other Exceptions do not, for some - # reason. They do not need, and shouldn't, be handled here. + # reason. They do not need to be, and shouldn't be, handled here. match = self.console_pattern.search(msg) inStr = match.groupdict().get("inStr", "") if inStr: @@ -835,56 +947,48 @@ def _write(self, msg, stream_type=StreamType.STDOUT): indent = self.getIndentForCodeTracebackLine(msg) msg = "{}{}{}\n".format(msg, indent, consoleLine) - # To make it easier to see relevant lines of a traceback, optionally insert - # a newline separating internal PrEditor code from the code run by user. - if self.addSepNewline: - if sepPreditorTrace: - msg = "\n" + msg - self.addSepNewline = False - - preditorCalls = ("cmdresult = e", "exec(compiled,") - if msg.strip().startswith(preditorCalls): - self.addSepNewline = True - - # Error tracebacks and logging.stack_info supply msg's differently, - # so modify it here, so we get consistent results. - msg = msg.replace("\n\n", "\n") - - if info and doHyperlink and not isConsolePrEdit: - fileStart = info.get("fileStart") - fileEnd = info.get("fileEnd") - lineNum = info.get("lineNum") - - toolTip = 'Open "{}" at line number {}'.format(filename, lineNum) - if isWorkbox: - split = filename.split(':') - workboxIdx = split[-1] - filename = '' + showInternalTrace = not hideInternalTrace + if showInternalTrace or not self.inInternalTraceLines: + if ( + info + and self.errorHyperLinksChoice + and not isConsolePrEdit + and ((not self.inInternalTraceLines) or (not inhibitInternalHyperlinks)) + ): + fileStart = info.get("fileStart") + fileEnd = info.get("fileEnd") + lineNum = info.get("lineNum") + + toolTip = 'Open "{}" at line number {}'.format(filename, lineNum) + if isWorkbox: + split = filename.split(':') + workboxIdx = split[-1] + filename = '' + else: + filename = filename + workboxIdx = '' + href = '{}, {}, {}'.format(filename, workboxIdx, lineNum) + + # Insert initial, non-underlined text + cursor.insertText(msg[:fileStart]) + + # Insert hyperlink + fmt = cursor.charFormat() + fmt.setAnchor(True) + fmt.setAnchorHref(href) + fmt.setFontUnderline(True) + fmt.setToolTip(toolTip) + cursor.insertText(msg[fileStart:fileEnd], fmt) + + # Insert the rest of the msg + fmt.setAnchor(False) + fmt.setAnchorHref('') + fmt.setFontUnderline(False) + fmt.setToolTip('') + cursor.insertText(msg[fileEnd:], fmt) else: - filename = filename - workboxIdx = '' - href = '{}, {}, {}'.format(filename, workboxIdx, lineNum) - - # Insert initial, non-underlined text - cursor.insertText(msg[:fileStart]) - - # Insert hyperlink - fmt = cursor.charFormat() - fmt.setAnchor(True) - fmt.setAnchorHref(href) - fmt.setFontUnderline(True) - fmt.setToolTip(toolTip) - cursor.insertText(msg[fileStart:fileEnd], fmt) - - # Insert the rest of the msg - fmt.setAnchor(False) - fmt.setAnchorHref('') - fmt.setFontUnderline(False) - fmt.setToolTip('') - cursor.insertText(msg[fileEnd:], fmt) - else: - # Non-hyperlink output - self.insertPlainText(msg) + # Non-hyperlink output + self.insertPlainText(msg) # Update the display of the console if enough time has passed and enabled self.maybeRepaint() diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 24f7a9a2..6c4f4cd3 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -1414,11 +1414,14 @@ def recordPrefs(self, manual=False, disableFileMonitoring=False): 'autoSaveSettings': self.autoSaveEnabled(), 'promptOnLinkedChange': self.promptOnLinkedChange(), 'autoPrompt': self.uiAutoPromptCHK.isChecked(), - 'errorHyperlinks': self.uiErrorHyperlinksCHK.isChecked(), 'uiStatusLbl_limit': self.uiStatusLBL.limit(), 'textEditorPath': self.textEditorPath, 'textEditorCmdTempl': self.textEditorCmdTempl, + # Tracebacks + 'errorHyperlinks': self.uiErrorHyperlinksCHK.isChecked(), + 'inhibitInternalLinks': self.uiInhibitInternalLinksCHK.isChecked(), 'separateTraceback': self.uiSeparateTracebackCHK.isChecked(), + 'hideInternalTraceback': self.uiHideInternalTracebackCHK.isChecked(), 'currentStyleSheet': self._stylesheet, 'flash_time': self.uiFlashTimeSPIN.value(), 'find_files_regex': self.uiFindInWorkboxesWGT.uiRegexBTN.isChecked(), @@ -1703,7 +1706,6 @@ def restorePrefs(self, skip_geom=False): self.setAutoSaveEnabled(pref.get('autoSaveSettings', True)) self.setPromptOnLinkedChange(pref.get('promptOnLinkedChange', True)) self.uiAutoPromptCHK.setChecked(pref.get('autoPrompt', False)) - self.uiErrorHyperlinksCHK.setChecked(pref.get('errorHyperlinks', True)) self.uiStatusLBL.setLimit(pref.get('uiStatusLbl_limit', 5)) # Find Files settings @@ -1724,7 +1726,15 @@ def restorePrefs(self, skip_geom=False): self.textEditorPath = pref.get('textEditorPath', defaultExePath) self.textEditorCmdTempl = pref.get('textEditorCmdTempl', defaultCmd) + # Tracebacks + self.uiErrorHyperlinksCHK.setChecked(pref.get('errorHyperlinks', True)) + self.uiInhibitInternalLinksCHK.setChecked( + pref.get('inhibitInternalLinks', True) + ) self.uiSeparateTracebackCHK.setChecked(pref.get('separateTraceback', True)) + self.uiHideInternalTracebackCHK.setChecked( + pref.get('hideInternalTraceback', True) + ) self.uiWordWrapCHK.setChecked(pref.get('wordWrap', True)) self.setWordWrap(self.uiWordWrapCHK.isChecked()) diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index 7443a97e..ca66f419 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -158,7 +158,7 @@ 0 0 938 - 447 + 446 @@ -523,6 +523,13 @@ + + + + Inhibit hyperlinks for internal code + + + @@ -530,6 +537,13 @@ + + + + Hide internal PrEditor traceback + + + @@ -911,7 +925,7 @@ Must be at least 1 0 0 958 - 21 + 22