diff --git a/README.md b/README.md index 3e59abad..5bcf245d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ blocks([...](https://docs.python.org/3/glossary.html#term-...)), use the workbox logging levels. * You can install logging handlers that have had PrEditor plugins written for them. * Known python logger levels are saved and restored. +* **OutputConsole:** Selectively shows output from stdout, stderr, specific python +loggers, and tracebacks(not using stderr). This can be used in various widgets to +show selected output. See [examples/output_console.py](examples/output_console.py) +for an example of the various modes. * All code is run in `__main__`. In code you can add objects to it for inspection in PrEditor. * `Ctrl + Shift + PgUp/PgDown` changes focus between the console and workbox. * `Ctrl + Alt + Shift + PgUp/PgDown` changes focus and copies the current prompt @@ -211,3 +215,46 @@ or add menu items. When using this plugin, make sure to use the * `preditor.plug.logging_handlers`: Used to add custom python logging handlers to the LoggingLevelButton's handlers sub-menus. This allows you to install a handler instance on a specific logging object. + +# Qt Designer integration + +PrEditor includes some reusable widgets that are useful to integrate into your +own custom interfaces. These can be directly imported and added but PrEditor also +defines Qt Designer plugins so you can directly add them to your designer files. + +Note: This has currently only been tested using [PyQt5](https://pypi.org/project/pyqt5-tools/)/[PyQt6](https://pypi.org/project/pyqt6-tools/). + +To load the plugins append the path to [preditor/gui/qtdesigner](/preditor/gui/qtdesigner) +to the `PYQTDESIGNERPATH` environment variable. + +# Change Log + +* **2.0.0:** OutputConsole, link files to workboxe tabs and Workbox editing history + * New reusable `OutputConsole` widget that optionally shows stdout, stderr, logging messages, and tracebacks. See the [example](/examples/output_console.py) implementation for details. + * Workbox tabs can be linked to files and edited externally + * Workbox content change history is versioned and you can quickly switch between the versions. + * Recently closed workbox tabs can be be re-opened + * Workbox preference saving has been re-worked. It will automatically migrate + to the new setup so you won't loose your current workbox tab contents. However + after upgrading if you want to switch back to the v1.X release see details below. + +
+ + In the rare case that you must revert to older Preditor (v1.X), you will only + see the workboxes you had when you updated to PrEditor v2.0. When you switch + back to v2.0 again, you still may only see those same workboxes. This can be + fixed with these steps, which in summary is to replace `preditor_pref.json` + with one of the backups of that file. + + * Options Menu > Preferences + * In the `Prefs files on disk` section, click Browse. An Explorer window opens. + * Close PrEditor + * Go into the `prefs_bak` folder + * Sort by name or by date, so most recent files are at the top + * Look for a backup that is at least slightly larger than recent ones. If they are all the same size, go with the latest one. + * Copy that into the parent directory (ie PrEditor) + * Remove `preditor_pref.json` + * Rename the `preditor_pref.json` file you copied, so it is `preditor_pref.json` + * Restart PrEditor. Check if it has all your workboxes. + * If it still isn't correct, do a little sleuthing or trial and error to find the correct backup to use. +
diff --git a/examples/output_console.py b/examples/output_console.py new file mode 100644 index 00000000..074f6fbf --- /dev/null +++ b/examples/output_console.py @@ -0,0 +1,162 @@ +"""Example of using `preditor.gui.output_console.OutputConsole` in a UI. + +`python output_console.py` +""" + + +import logging +import sys + +import Qt +from Qt.QtCore import QDateTime +from Qt.QtWidgets import QApplication, QMainWindow + +import preditor + +logger_a = logging.getLogger("logger_a") +logger_a_child = logging.getLogger("logger_a.child") +logger_b = logging.getLogger("logger_b") +logger_c = logging.getLogger("logger_c") + + +class ExampleApp(QMainWindow): + def __init__(self, parent=None): + super().__init__(parent=parent) + # Use a .ui file to simplify the example code setup. + Qt.QtCompat.loadUi(__file__.replace(".py", ".ui"), self) + + # Connect signals to test buttons + self.uiClearBTN.released.connect(self.clear_all) + self.uiAllLoggingDebugBTN.released.connect(self.all_logging_level_debug) + self.uiAllLoggingWarningBTN.released.connect(self.all_logging_level_warning) + self.uiLoggingCriticalBTN.released.connect(self.level_critical) + self.uiLoggingErrorBTN.released.connect(self.level_error) + self.uiLoggingWarningBTN.released.connect(self.level_warning) + self.uiLoggingInfoBTN.released.connect(self.level_info) + self.uiLoggingDebugBTN.released.connect(self.level_debug) + self.uiErrorBTN.released.connect(self.raise_exception) + self.uiPrintTimeBTN.released.connect(self.print_time) + self.uiPrintTimeErrBTN.released.connect(self.print_time_stderr) + self.uiSendLoggingBTN.released.connect(self.send_logging) + + # 1. Create the preditor instance and connect to the console's controllers + plog = preditor.instance(parent=self, create=True) + preditor.connect_preditor(self) + self.uiAllLog.controller = plog + self.uiSelectLog.controller = plog + self.uiStdout.controller = plog + self.uiStderr.controller = plog + + # 2. Configure the various OutputConsole widgets. + # Note: this can be done in the .ui file, but for this example we will + # configure it in code. + + # See this method for how to configure uiAllLog to show all logging + # messages of all levels + self.all_logging_level_warning() + + # Configure uiSelectLog to show logging messages from specific handlers + self.uiSelectLog.logging_handlers = [ + ( + "logger_a,DEBUG,fmt=[%(levelname)s %(module)s.%(funcName)s " + "line:%(lineno)d] %(message)s" + ), + "logger_b,WARNING", + ] + # And show tracebacks without showing all stderr text + self.uiSelectLog.stream_echo_tracebacks = True + + # Configure uiStdout to only show stdout text + self.uiStdout.stream_echo_stdout = True + + # Configure uiStderr to only show stderr text + self.uiStderr.stream_echo_stderr = True + + def all_logging_level_debug(self): + """Update this widget to show up to debug messages for all loggers. + Hide the PyQt loggers as they just clutter the output for this demo. + """ + self.uiAllLog.logging_handlers = [ + "root,level=DEBUG", + "PyQt5,level=CRITICAL", + "PyQt6,level=CRITICAL", + ] + + def all_logging_level_warning(self): + """Update this widget to show up to warning messages for all loggers. + Hide the PyQt loggers as they just clutter the output for this demo. + """ + self.uiAllLog.logging_handlers = [ + "root,level=WARNING", + # Suppress PyQt logging messages like the .ui file parsing + # logging messages created when first showing the PrEditor instance. + "PyQt5,level=CRITICAL", + "PyQt6,level=CRITICAL", + ] + + def clear_all(self): + """Clear the text from all consoles""" + self.uiAllLog.clear() + self.uiSelectLog.clear() + self.uiStdout.clear() + self.uiStderr.clear() + + def level_critical(self): + logging.root.setLevel(logging.CRITICAL) + + def level_error(self): + logging.root.setLevel(logging.ERROR) + + def level_warning(self): + logging.root.setLevel(logging.WARNING) + + def level_info(self): + logging.root.setLevel(logging.INFO) + + def level_debug(self): + logging.root.setLevel(logging.DEBUG) + + def message_time(self): + return f"The time is: {QDateTime.currentDateTime().toString()}" + + def print_time(self): + print(self.message_time()) + + def print_time_stderr(self): + print(self.message_time(), file=sys.stderr) + + def raise_exception(self): + raise RuntimeError(self.message_time()) + + def send_logging(self): + logger_a.critical("A critical msg for logger_a") + logger_a.error("A error msg for logger_a") + logger_a.warning("A warning msg for logger_a") + logger_a.info("A info msg for logger_a") + logger_a.debug("A debug msg for logger_a") + logger_a_child.warning("A warning msg for logger_a.child") + logger_a_child.debug("A debug msg for logger_a.child") + logger_b.warning("A warning msg for logger_b") + logger_b.debug("A debug msg for logger_b") + logger_c.warning("A warning msg for logger_c") + logger_c.debug("A debug msg for logger_c") + logging.root.warning("A warning msg for logging.root") + logging.root.debug("A debug msg for logging.root") + + +if __name__ == '__main__': + # Configure PrEditor for this application, start capturing all text output + # from stderr/stdout so once PrEditor is launched, it can show this text. + # This does not initialize any QtGui/QtWidgets. + preditor.configure( + # This is the name used to store PrEditor preferences and workboxes + # specific to this application. + 'output_console', + ) + + # Create a Gui Application allowing the user to show PrEditor + app = QApplication(sys.argv) + main_gui = ExampleApp() + + main_gui.show() + app.exec_() diff --git a/examples/output_console.ui b/examples/output_console.ui new file mode 100644 index 00000000..9107b642 --- /dev/null +++ b/examples/output_console.ui @@ -0,0 +1,203 @@ + + + Console + + + + 0 + 0 + 667 + 941 + + + + OutputConsole Demo + + + + + + + Qt::Vertical + + + + All logging + + + + + + + + + + + Warning + + + + + + + Debug + + + + + + + Set the logging level filter for this widget. This is affected by the global logging levels set at the bottom of this UI, so to see debug output you must set both to debug. + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + Select Logging + + + + + + + + + + Stdout + + + + + + + + + + Stderr + + + + + + + + + + + + + + + Clear All + + + + + + + Raise Exception + + + + + + + Print Time Stdout + + + + + + + Print Time Stderr + + + + + + + Send Logging Messages + + + + + + + + + + + Set Logging Level + + + + + + + Critical + + + + + + + Error + + + + + + + Warning + + + + + + + Info + + + + + + + Debug + + + + + + + + + + + OutputConsole + QTextEdit +
preditor.gui.output_console
+
+
+ + +
diff --git a/preditor/__init__.py b/preditor/__init__.py index f7083083..8ba23ad3 100644 --- a/preditor/__init__.py +++ b/preditor/__init__.py @@ -4,8 +4,6 @@ import os import sys -from Qt.QtCore import Qt - from . import osystem from .config import PreditorConfig from .plugins import Plugins @@ -178,17 +176,9 @@ def launch(run_workbox=False, app_id=None, name=None, standalone=False): run_workbox=run_workbox, name=name, standalone=standalone ) - # Show the PrEditor instance and make sure it regains focus and visibility - widget.show() - # If the instance was already shown, raise it to the top and make - # it regain focus. - widget.activateWindow() - widget.raise_() - widget.setWindowState( - widget.windowState() & ~Qt.WindowState.WindowMinimized - | Qt.WindowState.WindowActive - ) - widget.console().setFocus() + # Show the PrEditor instance, make sure it regains focus, visibility and is + # raised to the top. + widget.launch(focus=True) app.start() return widget diff --git a/preditor/constants.py b/preditor/constants.py new file mode 100644 index 00000000..38c2f164 --- /dev/null +++ b/preditor/constants.py @@ -0,0 +1,13 @@ +import enum + + +class StreamType(enum.Flag): + """Different types of streams used by PrEditor.""" + + STDERR = enum.auto() + STDIN = enum.auto() + STDOUT = enum.auto() + CONSOLE = enum.auto() + """Write directly to the console ignoring STDERR/STDOUT filters.""" + RESULT = enum.auto() + """Write directly to ConsolePrEdit's result output without using stdout/err.""" diff --git a/preditor/debug.py b/preditor/debug.py index dd0855e8..ebe2aad1 100644 --- a/preditor/debug.py +++ b/preditor/debug.py @@ -10,7 +10,7 @@ class FileLogger: def __init__(self, stdhandle, logfile, _print=True, clearLog=True): - self._stdhandle = stdhandle + self.old_stream = stdhandle self._logfile = logfile self._print = _print if clearLog: @@ -19,24 +19,25 @@ def __init__(self, stdhandle, logfile, _print=True, clearLog=True): def clear(self, stamp=False): """Removes the contents of the log file.""" - open(self._logfile, 'w').close() + open(self._logfile, 'w', newline="\n", encoding="utf-8").close() if stamp: print(self.stamp()) def flush(self): - if self._stdhandle: - self._stdhandle.flush() + if self.old_stream: + self.old_stream.flush() def stamp(self): msg = '--------- Date: {today} Version: {version} ---------' return msg.format(today=datetime.datetime.today(), version=sys.version) def write(self, msg): - with open(self._logfile, 'a', encoding="utf-8") as f: + # Newline forces windows to write unix style newlines + with open(self._logfile, 'a', newline="\n", encoding="utf-8") as f: f.write(msg) if self._print: - self._stdhandle.write(msg) + self.old_stream.write(msg) def logToFile(path, stdout=True, stderr=True, useOldStd=True, clearLog=True): @@ -70,9 +71,9 @@ def logToFile(path, stdout=True, stderr=True, useOldStd=True, clearLog=True): # Update any StreamHandler's that were setup using the old stdout/err if stdout: - StreamHandlerHelper.replace_stream(sys.stdout._stdhandle, sys.stdout) + StreamHandlerHelper.replace_stream(sys.stdout.old_stream, sys.stdout) if stderr: - StreamHandlerHelper.replace_stream(sys.stderr._stdhandle, sys.stderr) + StreamHandlerHelper.replace_stream(sys.stderr.old_stream, sys.stderr) def printCallingFunction(compact=False): diff --git a/preditor/excepthooks.py b/preditor/excepthooks.py index d862db12..373f5c75 100644 --- a/preditor/excepthooks.py +++ b/preditor/excepthooks.py @@ -7,6 +7,7 @@ from . import config, plugins from .contexts import ErrorReport +from .weakref import WeakList class PreditorExceptHook(object): @@ -20,6 +21,12 @@ class PreditorExceptHook(object): to the list before this class is initialized. """ + callbacks = WeakList() + """A list of callback called by `call_callbacks()` if enabled. This can be + used to notify other widgets of tracebacks. The callback must accept + `*exc_info` arguments. + """ + def __init__(self, base_excepthook=None): self.base_excepthook = base_excepthook or sys.__excepthook__ @@ -29,13 +36,22 @@ def __init__(self, base_excepthook=None): def __call__(self, *exc_info): """Run when an exception is raised and calls all `config.excepthooks`.""" - for plugin in config.excepthooks: - if plugin is None: - continue - plugin(*exc_info) - - # Clear any ErrorReports that were generated by this exception handling - ErrorReport.clearReports() + try: + for plugin in config.excepthooks: + if plugin is None: + continue + plugin(*exc_info) + + # Clear any ErrorReports that were generated by this exception handling + ErrorReport.clearReports() + except Exception: + # When developing for preditor.stream and the console, a exception may + # prevent showing the traceback normally. This last ditch method prints + # the traceback to the original stderr stream so it can be debugged. + # Without this you might get little to no output to work with. + print(" PrEditor excepthooks failed ".center(79, "-"), file=sys.__stderr__) + traceback.print_exc(file=sys.__stderr__) + print(" PrEditor excepthooks failed ".center(79, "-"), file=sys.__stderr__) def default_excepthooks(self): """Returns default excepthooks handlers. @@ -46,6 +62,7 @@ def default_excepthooks(self): """ return [ self.call_base_excepthook, + self.call_callbacks, self.ask_to_show_logger, ] @@ -59,12 +76,30 @@ def call_base_excepthook(self, *exc_info): is not printed in-line with the prompt. This also provides visual separation between tracebacks, when received consecutively. """ - print("") + # Print the newline to stderr so it will show up in the same stream as + # the traceback will be printed to. + print("", file=sys.stderr) try: self.base_excepthook(*exc_info) except (TypeError, NameError): sys.__excepthook__(*exc_info) + @classmethod + def call_callbacks(cls, *exc_info): + for callback in cls.callbacks: + try: + callback(*exc_info) + except Exception: + print( + " PrEditor excepthook callback failed ".center(79, "-"), + file=sys.__stderr__, + ) + traceback.print_exc(file=sys.__stderr__) + print( + " PrEditor excepthook callback failed ".center(79, "-"), + file=sys.__stderr__, + ) + def ask_to_show_logger(self, *exc_info): """Show a dialog asking the user how to handle the error.""" if config.error_dialog_class is True: diff --git a/preditor/gui/__init__.py b/preditor/gui/__init__.py index e0e3cde2..279643ee 100644 --- a/preditor/gui/__init__.py +++ b/preditor/gui/__init__.py @@ -1,9 +1,5 @@ -from __future__ import absolute_import - import re -from functools import partial -from Qt.QtCore import Property from Qt.QtGui import QCursor from Qt.QtWidgets import QStackedWidget, QToolTip @@ -11,58 +7,6 @@ from .window import Window # noqa: F401 -def QtPropertyInit(name, default, callback=None, typ=None): - """Initializes a default Property value with a usable getter and setter. - - You can optionally pass a function that will get called any time the property - is set. If using the same callback for multiple properties, you may want to - use the preditor.decorators.singleShot decorator to prevent your function getting - called multiple times at once. This callback must accept the attribute name and - value being set. - - Example: - class TestClass(QWidget): - def __init__(self, *args, **kwargs): - super(TestClass, self).__init__(*args, **kwargs) - - stdoutColor = QtPropertyInit('_stdoutColor', QColor(0, 0, 255)) - pyForegroundColor = QtPropertyInit('_pyForegroundColor', QColor(0, 0, 255)) - - Args: - name(str): The name of internal attribute to store to and lookup from. - default: The property's default value. This will also define the Property type - if typ is not set. - callback(callable): If provided this function is called when the property is - set. - typ (class, optional): If not None this value is used to specify the type of - the Property. This is useful when you need to specify a property as python's - object but pass a default value of a given class. - - Returns: - Property - """ - - def _getattrDefault(default, self, attrName): - try: - value = getattr(self, attrName) - except AttributeError: - setattr(self, attrName, default) - return default - return value - - def _setattrCallback(callback, attrName, self, value): - setattr(self, attrName, value) - if callback: - callback(self, attrName, value) - - ga = partial(_getattrDefault, default) - sa = partial(_setattrCallback, callback, name) - # Use the default value's class if typ is not provided. - if typ is None: - typ = default.__class__ - return Property(typ, fget=(lambda s: ga(s, name)), fset=(lambda s, v: sa(s, v))) - - def handleMenuHovered(action): """Actions in QMenus which are not descendants of a QToolBar will not show their toolTips, because... Reasons? diff --git a/preditor/gui/console.py b/preditor/gui/console.py index 49322963..4e67ec06 100644 --- a/preditor/gui/console.py +++ b/preditor/gui/console.py @@ -1,97 +1,48 @@ -""" LoggerWindow class is an overloaded python interpreter for preditor""" -from __future__ import absolute_import, print_function +from __future__ import absolute_import -import os import re import string -import subprocess import sys import time import traceback from builtins import str as text -from fractions import Fraction from functools import partial +from typing import Optional import __main__ -from Qt import QtCompat from Qt.QtCore import QPoint, Qt, QTimer -from Qt.QtGui import ( - QColor, - QFontMetrics, - QKeySequence, - QTextCharFormat, - QTextCursor, - QTextDocument, -) -from Qt.QtWidgets import QAbstractItemView, QAction, QApplication, QTextEdit - -from .. import settings, stream -from ..streamhandler_helper import StreamHandlerHelper -from . import QtPropertyInit -from .codehighlighter import CodeHighlighter +from Qt.QtGui import QKeySequence, QTextCursor, QTextDocument +from Qt.QtWidgets import QAbstractItemView, QAction, QApplication, QWidget + +from .. import settings +from ..constants import StreamType +from ..utils import Truncate +from ..utils.cute import QtPropertyInit from .completer import PythonCompleter -from .suggest_path_quotes_dialog import SuggestPathQuotesDialog +from .console_base import ConsoleBase +from .loggerwindow import LoggerWindow -class ConsolePrEdit(QTextEdit): +class ConsolePrEdit(ConsoleBase): # Ensure the error prompt only shows up once. _errorPrompted = False - # These Qt Properties can be customized using style sheets. - commentColor = QtPropertyInit('_commentColor', QColor(0, 206, 52)) - errorMessageColor = QtPropertyInit('_errorMessageColor', QColor(Qt.GlobalColor.red)) - keywordColor = QtPropertyInit('_keywordColor', QColor(17, 154, 255)) - resultColor = QtPropertyInit('_resultColor', QColor(128, 128, 128)) - stdoutColor = QtPropertyInit('_stdoutColor', QColor(17, 154, 255)) - stringColor = QtPropertyInit('_stringColor', QColor(255, 128, 0)) - - def __init__(self, parent): - super(ConsolePrEdit, self).__init__(parent) - - # For Traceback workbox lines, use this regex pattern, so we can extract - # workboxName and lineNum. Note that Syntax errors present slightly - # differently than other Exceptions. - # SyntaxErrors: - # - Do NOT include the text ", in" followed by a module - # - DO include the offending line of code - # Other Exceptions - # - DO include the text ", in" followed by a module - # - Do NOT include the offending line of code if from stdIn (ie - # a workbox) - # So we will use the presence of the text ", in" to tell use whether to - # fake the offending code line or not. - pattern = r'File ":(?P.*)", ' - pattern += r'line (?P\d{1,6})' - pattern += r'(?P, in)?' - self.workbox_pattern = re.compile(pattern) - - # Define a pattern to capture info from tracebacks. The newline/$ section - # handle SyntaxError output that does not include the `, in ...` portion. - pattern = r'File "(?P.*)", line (?P\d{1,10})(, in|\r\n|\n|$)' - self.traceback_pattern = re.compile(pattern) - - self._consolePrompt = '>>> ' - # Note: Changing _outputPrompt may require updating resource\lang\python.xml - # If still using a # - self._outputPrompt = '#Result: ' + _consolePrompt = '>>> ' + + # Note: Changing _outputPrompt may require updating resource\lang\python.xml + # If still using a # + _outputPrompt = '#Result: ' + + def __init__(self, parent: QWidget, controller: Optional[LoggerWindow] = None): + super(ConsolePrEdit, self).__init__(parent, controller=controller) + # Method used to update the gui when code is executed self.clearExecutionTime = None self.reportExecutionTime = None - # Create the highlighter - highlight = CodeHighlighter(self, 'Python') - self.setCodeHighlighter(highlight) - # store the error buffer self._completer = None - # If populated, also write to this interface - self.outputPipe = None - - self._firstShow = True - - self.addSepNewline = False - # When executing code, that takes longer than this seconds, flash the window self.flash_window = None @@ -103,30 +54,6 @@ def __init__(self, parent): # create the completer self.setCompleter(PythonCompleter(self)) - # sys.__stdout__ doesn't work if some third party has implemented their own - # override. Use these to backup the current logger so the logger displays - # output, but application specific consoles also get the info. - self.stdout = None - self.stderr = None - self._errorLog = None - - # overload the sys logger - self.stream_manager = stream.install_to_std() - # Redirect future writes directly to the console, add any previous writes - # to the console and free up the memory consumed by previous writes as we - # assume this is likely to be the only callback added to the manager. - self.stream_manager.add_callback( - self.pre_write, replay=True, disable_writes=True, clear=True - ) - # Store the current outputs - self.stdout = sys.stdout - self.stderr = sys.stderr - self._errorLog = sys.stderr - - # Update any StreamHandler's that were setup using the old stdout/err - StreamHandlerHelper.replace_stream(self.stdout, sys.stdout) - StreamHandlerHelper.replace_stream(self.stderr, sys.stderr) - self.uiClearToLastPromptACT = QAction('Clear to Last', self) self.uiClearToLastPromptACT.triggered.connect(self.clearToLastPrompt) self.uiClearToLastPromptACT.setShortcut( @@ -134,9 +61,6 @@ def __init__(self, parent): ) self.addAction(self.uiClearToLastPromptACT) - self.x = 0 - 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. if not self.cursorWidth(): @@ -147,27 +71,6 @@ def __init__(self, parent): # it on. self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - def setCodeHighlighter(self, highlight): - """Set the code highlighter for the console - - Args: - highlight (CodeHighlighter): The instantiated CodeHighlighter - """ - self._uiCodeHighlighter = highlight - - def codeHighlighter(self): - """Get the code highlighter for the console - - Returns: - _uiCodeHighlighter (CodeHighlighter): The instantiated CodeHighlighter - """ - return self._uiCodeHighlighter - - def contextMenuEvent(self, event): - menu = self.createStandardContextMenu() - menu.setFont(self.window().font()) - menu.exec(self.mapToGlobal(event.pos())) - def doubleSingleShotSetScrollValue(self, origPercent): """This double QTimer.singleShot monkey business seems to be the only way to get scroll.maximum() to update properly so that we calc newValue @@ -191,76 +94,6 @@ def singleShotSetScrollValue(self, origPercent): finally: self.setUpdatesEnabled(True) - def setConsoleFont(self, font): - """Set the console's font and adjust the tabStopWidth""" - - # Capture the scroll bar's current position (by percentage of max) - origPercent = None - scroll = self.verticalScrollBar() - if scroll.maximum(): - origPercent = Fraction(scroll.value(), scroll.maximum()) - - # Set console and completer popup fonts - self.setFont(font) - self.completer().popup().setFont(font) - - # Set the setTabStopWidth for the console's font - tab_width = 4 - # TODO: Make tab_width a general user setting - if hasattr(self, "window") and "LoggerWindow" in str(type(self.window())): - # If parented to a LoggerWindow, get the tab_width from it's workboxes - workbox = self.window().current_workbox() - if workbox: - tab_width = workbox.__tab_width__() - fontPixelWidth = QFontMetrics(font).horizontalAdvance(" ") - self.setTabStopDistance(fontPixelWidth * tab_width) - - # Scroll to same relative position where we started - 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. - """ - 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.mousePressPos - left = event.button() == Qt.MouseButton.LeftButton - anchor = self.anchorAt(event.pos()) - - if samePos and left and anchor: - self.errorHyperlink(anchor) - self.mousePressPos = None - - QApplication.restoreOverrideCursor() - ret = super(ConsolePrEdit, self).mouseReleaseEvent(event) - - return ret - def keyReleaseEvent(self, event): """Override of keyReleaseEvent to determine when to end navigation of previous commands @@ -270,94 +103,6 @@ def keyReleaseEvent(self, event): else: event.ignore() - 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. - - The text editor defaults to SublimeText3, in the normal install directory - """ - window = self.window() - - # Bail if Error Hyperlinks setting is not turned on or we don't have an anchor. - doHyperlink = ( - hasattr(window, 'uiErrorHyperlinksCHK') - and window.uiErrorHyperlinksCHK.isChecked() - and anchor - ) - if not doHyperlink: - return - - # info is a comma separated string, in the form: "filename, workboxIdx, lineNum" - info = anchor.split(', ') - modulePath = info[0] - workboxName = info[1] - lineNum = info[2] - - # fetch info from LoggerWindow - exePath = '' - cmdTempl = '' - if hasattr(window, 'textEditorPath'): - exePath = window.textEditorPath - cmdTempl = window.textEditorCmdTempl - - # Bail if not setup properly - if not workboxName: - msg = ( - "Cannot use traceback hyperlink (Correct the path with Options " - "> Set Preferred Text Editor Path).\n" - ) - if not exePath: - msg += "No text editor path defined." - print(msg) - return - if not os.path.exists(exePath): - msg += "Text editor executable does not exist: {}".format(exePath) - print(msg) - return - if not cmdTempl: - msg += "No text editor Command Prompt command template defined." - print(msg) - return - if modulePath and not os.path.exists(modulePath): - msg += "Specified module path does not exist: {}".format(modulePath) - print(msg) - return - - if modulePath: - # Check if cmdTempl filepaths aren't wrapped in double=quotes to handle - # spaces. If not, suggest to user to update the template, offering the - # suggested change. - pattern = r"(?"' - quotedCmdTempl = re.sub(pattern, repl, cmdTempl) - if quotedCmdTempl != cmdTempl: - # Instantiate dialog to maybe show (unless user previously chose "Don't - # ask again") - dialog = SuggestPathQuotesDialog( - self.window(), cmdTempl, quotedCmdTempl - ) - self.window().maybeDisplayDialog(dialog) - - # Refresh cmdTempl in case user just had it changed. - cmdTempl = window.textEditorCmdTempl - - # Attempt to create command from template and run the command - try: - command = cmdTempl.format( - exePath=exePath, modulePath=modulePath, lineNum=lineNum - ) - subprocess.Popen(command) - except (ValueError, OSError): - msg = "The provided text editor command is not valid:\n {}" - msg = msg.format(cmdTempl) - print(msg) - elif workboxName is not None: - workbox = window.workbox_for_name(workboxName, visible=True) - lineNum = int(lineNum) - workbox.__goto_line__(lineNum) - workbox.setFocus() - def getPrevCommand(self): """Find and display the previous command in stack""" self._prevCommandIndex -= 1 @@ -391,7 +136,7 @@ def setCommand(self): def clear(self): """clears the text in the editor""" - QTextEdit.clear(self) + super().clear() self.startInputLine() def clearToLastPrompt(self): @@ -426,31 +171,14 @@ def completer(self): """returns the completer instance that is associated with this editor""" return self._completer - def getWorkboxLine(self, name, lineNum): - """Python 3 does not include in tracebacks the code line if it comes from - stdin, which is the case for PrEditor workboxes, so we fake it. This method - will return the line of code at lineNum, from the workbox with the provided - name. - - Args: - name (str): The name of the workbox from which to get a line of code - lineNum (int): The number of the line to return - - Returns: - txt (str): The line of text found - """ - workbox = self.window().workbox_for_name(name) - if not workbox: - return None - - num_lines = workbox.__num_lines__() - if lineNum > num_lines: - return None - txt = workbox.__text_part__(lineNum=lineNum).strip() + "\n" - return txt - def executeString( - self, commandText, consoleLine=None, filename='', extraPrint=True + self, + commandText, + consoleLine=None, + filename='', + extraPrint=True, + echoResult=False, + truncate=False, ): # These vars helps with faking code lines in tracebacks for stdin input, which # workboxes are, and py3 doesn't include in the traceback @@ -465,7 +193,7 @@ def executeString( line = line[1:] if line.startswith(self.prompt()) and extraPrint: - print("") + self.write("\n", stream_type=StreamType.RESULT) cmdresult = None # https://stackoverflow.com/a/29456463 @@ -495,17 +223,29 @@ def executeString( self.reportExecutionTime((delta, commandText)) # Provide user feedback when running long code execution. - flash_time = self.window().uiFlashTimeSPIN.value() - if self.flash_window and flash_time and delta >= flash_time: - if settings.OS_TYPE == "Windows": - try: - from casement import utils - except ImportError: - # If casement is not installed, flash window is disabled - pass - else: - hwnd = int(self.flash_window.winId()) - utils.flash_window(hwnd) + if self.controller: + flash_time = self.controller.uiFlashTimeSPIN.value() + if self.flash_window and flash_time and delta >= flash_time: + if settings.OS_TYPE == "Windows": + try: + from casement import utils + except ImportError: + # If casement is not installed, flash window is disabled + pass + else: + hwnd = int(self.flash_window.winId()) + utils.flash_window(hwnd) + + if echoResult and wasEval: + # If the selected code was a statement print the result of the statement. + ret = repr(cmdresult) + self.startOutputLine() + if truncate: + self.write( + Truncate(ret).middle(100) + "\n", stream_type=StreamType.RESULT + ) + else: + self.write(ret + "\n", stream_type=StreamType.RESULT) return cmdresult, wasEval @@ -568,7 +308,7 @@ def focusInEvent(self, event): """overload the focus in event to ensure the completer has the proper widget""" if self.completer(): self.completer().setWidget(self) - QTextEdit.focusInEvent(self, event) + super().focusInEvent(event) def insertCompletion(self, completion): """inserts the completion text into the editor""" @@ -698,17 +438,13 @@ def keyPressEvent(self, event): # Process all events we do not want to override if not (ctrlSpace or ctrlM or ctrlI): - QTextEdit.keyPressEvent(self, event) - - window = self.window() - if ctrlI: - hasToggleCase = hasattr(window, 'toggleCaseSensitive') - if hasToggleCase: - window.toggleCaseSensitive() - if ctrlM: - hasCycleMode = hasattr(window, 'cycleCompleterMode') - if hasCycleMode: - window.cycleCompleterMode() + super().keyPressEvent(event) + + if self.controller: + if ctrlI: + self.controller.toggleCaseSensitive() + if ctrlM: + self.controller.cycleCompleterMode() # check for particular events for the completion if completer: @@ -741,7 +477,10 @@ def keyPressEvent(self, event): # If option chosen, if the exact prefix exists in the # possible completions, highlight it, even if it's not the # topmost completion. - if self.window().uiHighlightExactCompletionCHK.isChecked(): + if ( + self.controller + and self.controller.uiHighlightExactCompletionCHK.isChecked() + ): for i in range(completer.completionCount()): completer.setCurrentRow(i) curCompletion = completer.currentCompletion() @@ -806,6 +545,16 @@ def moveToHome(self): cursor.movePosition(QTextCursor.MoveOperation.Right, mode, len(self.prompt())) self.setTextCursor(cursor) + def onFirstShow(self, event) -> bool: + if not super().onFirstShow(event): + # It's already been shown, nothing to do. + return False + + # This is the first showing of this widget, ensure the first input + # prompt is styled by any active stylesheet + self.startInputLine() + return True + def outputPrompt(self): """The prompt used to output a result.""" return self._outputPrompt @@ -820,48 +569,11 @@ def setCompleter(self, completer): completer.setWidget(self) completer.activated.connect(self.insertCompletion) - def showEvent(self, event): - # _firstShow is used to ensure the first imput prompt is styled by any active - # stylesheet - if self._firstShow: - self.startInputLine() - self._firstShow = False - - # Redfine highlight variables now that stylesheet may have been updated - self.codeHighlighter().defineHighlightVariables() - - super(ConsolePrEdit, self).showEvent(event) - def startInputLine(self): """create a new command prompt line""" self.startPrompt(self.prompt()) self._prevCommandIndex = 0 - def startPrompt(self, prompt): - """create a new command prompt line with the given prompt - - Args: - prompt(str): The prompt to start the line with. If this prompt - is already the only text on the last line this function does nothing. - """ - self.moveCursor(QTextCursor.MoveOperation.End) - - # if this is not already a new line - if self.textCursor().block().text() != prompt: - charFormat = QTextCharFormat() - self.setCurrentCharFormat(charFormat) - - inputstr = prompt - if self.textCursor().block().text(): - inputstr = '\n' + inputstr - - self.insertPlainText(inputstr) - - scroll = self.verticalScrollBar() - maximum = scroll.maximum() - if maximum is not None: - scroll.setValue(maximum) - def startOutputLine(self): """Create a new line to show output text.""" self.startPrompt(self._outputPrompt) @@ -876,197 +588,15 @@ def removeCurrentLine(self): self.textCursor().deletePreviousChar() self.insertPlainText("\n") - def parseErrorHyperLinkInfo(self, txt): - """Determine if txt is a File-info line from a traceback, and if so, return info - dict. - """ - - ret = None - if not txt.lstrip().startswith("File "): - return ret - - match = self.traceback_pattern.search(txt) - if match: - filename = match.groupdict().get('filename') - lineNum = match.groupdict().get('lineNum') - fileStart = txt.find(filename) - fileEnd = fileStart + len(filename) - - ret = { - 'filename': filename, - 'fileStart': fileStart, - 'fileEnd': fileEnd, - 'lineNum': lineNum, - } - return ret - - @staticmethod - def getIndentForCodeTracebackLine(msg): - """Determine the indentation to recreate traceback lines - - Args: - msg (str): The traceback line - - Returns: - indent (str): A string of zero or more spaces used for indentation - """ - indent = "" - match = re.match(r"^ *", msg) - if match: - indent = match.group() * 2 - return indent - - def pre_write(self, msg, error=False): - """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. - """ - 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, error=error) - else: - self.write(msg, error=error) - - def write(self, msg, error=False): - """write the message to the logger""" - if not msg: - return - - # Convert the stream_manager's stream to the boolean value this function expects - error = error == stream.STDERR - # Check that we haven't been garbage collected before trying to write. - # This can happen while shutting down a QApplication like Nuke. - if QtCompat.isValid(self): - window = self.window() - doHyperlink = ( - hasattr(window, 'uiErrorHyperlinksCHK') - and window.uiErrorHyperlinksCHK.isChecked() - ) - sepPreditorTrace = ( - hasattr(window, 'uiSeparateTracebackCHK') - and window.uiSeparateTracebackCHK.isChecked() - ) - self.moveCursor(QTextCursor.MoveOperation.End) - - charFormat = QTextCharFormat() - if not error: - charFormat.setForeground(self.stdoutColor) - else: - charFormat.setForeground(self.errorMessageColor) - self.setCurrentCharFormat(charFormat) - - # If showing Error Hyperlinks... Sometimes (when a syntax error, at least), - # the last File-Info line of a traceback is issued in multiple messages - # starting with unicode paragraph separator (r"\u2029") and followed by a - # newline, so our normal string checks search won't work. Instead, we'll - # manually reconstruct the line. If msg is a newline, grab that current line - # and check it. If it matches,proceed using that line as msg - cursor = self.textCursor() - info = None - - if doHyperlink and msg == '\n': - cursor.select(QTextCursor.SelectionType.BlockUnderCursor) - line = cursor.selectedText() - - # Remove possible leading unicode paragraph separator, which really - # messes up the works - if line and line[0] not in string.printable: - line = line[1:] - - info = self.parseErrorHyperLinkInfo(line) - if info: - cursor.insertText("\n") - msg = "{}\n".format(line) - - # If showing Error Hyperlinks, display underline output, otherwise - # display normal output. Exclude ConsolePrEdits - info = info if info else self.parseErrorHyperLinkInfo(msg) - filename = info.get("filename", "") if info else "" - - # Determine if this is a workbox line of code, or code run directly - # in the console - isWorkbox = '' in filename or '' in filename - isConsolePrEdit = '' in filename - - # Starting in Python 3, tracebacks don't include the code executed - # for stdin, so workbox code won't appear. This attempts to include - # it. There is an exception for SyntaxErrors, which DO include the - # offending line of code, so in those cases (indicated by lack of - # inStr from the regex search) we skip faking the code line. - if isWorkbox: - match = self.workbox_pattern.search(msg) - workboxName = match.groupdict().get("workboxName") - lineNum = int(match.groupdict().get("lineNum")) - 1 - inStr = match.groupdict().get("inStr", "") - - workboxLine = self.getWorkboxLine(workboxName, lineNum) - if workboxLine and inStr: - indent = self.getIndentForCodeTracebackLine(msg) - msg = "{}{}{}".format(msg, indent, workboxLine) - - elif isConsolePrEdit: - consoleLine = self.consoleLine - 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 = '' - 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) - - # if a outputPipe was provided, write the message to that pipe - if self.outputPipe: - self.outputPipe(msg, error=error) + # This main console should have these settings enabled by default + stream_clear = QtPropertyInit('_stream_clear', True) + stream_disable_writes = QtPropertyInit('_stream_disable_writes', True) + stream_replay = QtPropertyInit('_stream_replay', True) + stream_echo_stderr = QtPropertyInit( + '_stream_echo_stderr', True, callback=ConsoleBase.update_streams + ) + stream_echo_stdout = QtPropertyInit( + '_stream_echo_stdout', True, callback=ConsoleBase.update_streams + ) + stream_echo_result = QtPropertyInit("_stream_echo_result", True) + """Enable StreamType.RESULT output when running code using PrEditor.""" diff --git a/preditor/gui/console_base.py b/preditor/gui/console_base.py new file mode 100644 index 00000000..d6564406 --- /dev/null +++ b/preditor/gui/console_base.py @@ -0,0 +1,751 @@ +import os +import re +import string +import subprocess +import traceback +from fractions import Fraction +from typing import Optional + +from Qt import QtCompat +from Qt.QtCore import Qt +from Qt.QtGui import ( + QColor, + QFontMetrics, + QIcon, + QKeySequence, + QTextCharFormat, + QTextCursor, +) +from Qt.QtWidgets import QAction, QApplication, QTextEdit, QWidget + +from .. import instance, resourcePath, stream +from ..constants import StreamType +from ..stream.console_handler import HandlerInfo +from ..utils.cute import QtPropertyInit +from .codehighlighter import CodeHighlighter +from .loggerwindow import LoggerWindow +from .suggest_path_quotes_dialog import SuggestPathQuotesDialog + + +class ConsoleBase(QTextEdit): + """Base class for a text widget used to show stdout/stderr writes.""" + + def __init__(self, parent: QWidget, controller: Optional[LoggerWindow] = None): + super().__init__(parent) + self.controller = controller + self._first_show = True + + # Create the highlighter + highlight = CodeHighlighter(self, 'Python') + self.setCodeHighlighter(highlight) + + self.addSepNewline = False + self.consoleLine = None + self.mousePressPos = None + + self.init_actions() + + def __repr__(self): + """The repr for this object including its objectName if set.""" + name = self.objectName() + if name: + name = f" named {name!r}" + module = type(self).__module__ + class_ = type(self).__name__ + + return f"<{module}.{class_}{name} object at 0x{id(self):016X}>" + + @classmethod + def __defineRegexPatterns(cls): + """Define various regex patterns to use to determine if the msg to write + is part of a traceback, and also to determine if it is from a workbox, or + from the console. + + We construct various parts of the patterns, and combine them into the + final patterns. The workbox pattern and console pattern share the line + and in_str parts. + """ + + # For Traceback workbox lines, use this regex pattern, so we can extract + # workboxName and lineNum. Note that Syntax errors present slightly + # differently than other Exceptions. + # SyntaxErrors: + # - Do NOT include the text ", in" followed by a module + # - DO include the offending line of code + # Other Exceptions + # - DO include the text ", in" followed by a module + # - Do NOT include the offending line of code if from stdIn (ie + # a workbox) + # So we will use the presence of the text ", in" to tell use whether to + # fake the offending code line or not. + + # Define pattern pieces + console_pattern = r'File "", ' + workbox_pattern = r'File ":(?P.*)", ' + line_pattern = r'line (?P\d{1,6})' + in_str_pattern = r'(?P, in)?' + + # Put the pattern pieces to together to make patterns + workbox_pattern = workbox_pattern + line_pattern + in_str_pattern + cls.workbox_pattern = re.compile(workbox_pattern) + + console_pattern = console_pattern + line_pattern + in_str_pattern + cls.console_pattern = re.compile(console_pattern) + + # Define a pattern to capture info from tracebacks. The newline/$ section + # handle SyntaxError output that does not include the `, in ...` portion. + pattern = r'File "(?P.*)", line (?P\d{1,10})(, in|\r\n|\n|$)' + cls.traceback_pattern = re.compile(pattern) + + 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 + self.startPrompt("") + # Add a horizontal rule + self.insertHtml("

") + + def contextMenuEvent(self, event): + """Builds a custom right click menu to show.""" + # Create the standard menu and allow subclasses to customize it + menu = self.createStandardContextMenu(event.pos()) + menu = self.update_context_menu(menu) + if self.controller: + menu.setFont(self.controller.font()) + menu.exec(self.mapToGlobal(event.pos())) + + @property + def controller(self) -> Optional[LoggerWindow]: + """Used to access workbox widgets and PrEditor settings that are needed. + + This must be set to a LoggerWindow instance. If not set then uses + `self.window()`. If this instance isn't a child of a LoggerWindow you must + set controller to an instance of a LoggerWindow. + """ + if self._controller: + return self._controller + controller = self.window() + if not isinstance(controller, LoggerWindow): + controller = instance(create=False) + return controller + + @controller.setter + def controller(self, value: LoggerWindow): + self._controller = value + + def codeHighlighter(self): + """Get the code highlighter for the console + + Returns: + _uiCodeHighlighter (CodeHighlighter): The instantiated CodeHighlighter + """ + return self._uiCodeHighlighter + + def setCodeHighlighter(self, highlight): + """Set the code highlighter for the console + + Args: + highlight (CodeHighlighter): The instantiated CodeHighlighter + """ + self._uiCodeHighlighter = highlight + + 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. + + The text editor defaults to SublimeText3, in the normal install directory + """ + if not self.controller: + # 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 + ) + if not doHyperlink: + return + + # info is a comma separated string, in the form: "filename, workboxIdx, lineNum" + info = anchor.split(', ') + modulePath = info[0] + workboxName = info[1] + lineNum = info[2] + + # fetch info from LoggerWindow + exePath = self.controller.textEditorPath + cmdTempl = self.controller.textEditorCmdTempl + + # Bail if not setup properly + if not workboxName: + msg = ( + "Cannot use traceback hyperlink (Correct the path with Options " + "> Set Preferred Text Editor Path).\n" + ) + if not exePath: + msg += "No text editor path defined." + print(msg) + return + if not os.path.exists(exePath): + msg += "Text editor executable does not exist: {}".format(exePath) + print(msg) + return + if not cmdTempl: + msg += "No text editor Command Prompt command template defined." + print(msg) + return + if modulePath and not os.path.exists(modulePath): + msg += "Specified module path does not exist: {}".format(modulePath) + print(msg) + return + + if modulePath: + # Check if cmdTempl filepaths aren't wrapped in double=quotes to handle + # spaces. If not, suggest to user to update the template, offering the + # suggested change. + pattern = r"(?"' + quotedCmdTempl = re.sub(pattern, repl, cmdTempl) + if quotedCmdTempl != cmdTempl: + # Instantiate dialog to maybe show (unless user previously chose "Don't + # ask again") + dialog = SuggestPathQuotesDialog( + self.controller, cmdTempl, quotedCmdTempl + ) + self.controller.maybeDisplayDialog(dialog) + + # Refresh cmdTempl in case user just had it changed. + cmdTempl = self.controller.textEditorCmdTempl + + # Attempt to create command from template and run the command + try: + command = cmdTempl.format( + exePath=exePath, modulePath=modulePath, lineNum=lineNum + ) + subprocess.Popen(command) + except (ValueError, OSError): + msg = "The provided text editor command is not valid:\n {}" + msg = msg.format(cmdTempl) + print(msg) + elif workboxName is not None: + workbox = self.controller.workbox_for_name(workboxName, visible=True) + lineNum = int(lineNum) + # Make the controller visible and focus on the workbox. This is not + # using preditor.launch on the assumption that some widget has already + # initialized it to store in self.controller. + self.controller.launch(focus=False) + workbox.__goto_line__(lineNum) + workbox.setFocus() + + @classmethod + def getIndentForCodeTracebackLine(cls, msg): + """Determine the indentation to recreate traceback lines + + Args: + msg (str): The traceback line + + Returns: + indent (str): A string of zero or more spaces used for indentation + """ + indent = "" + match = re.match(r"^ *", msg) + if match: + indent = match.group() * 2 + return indent + + def getWorkboxLine(self, name, lineNum): + """Python 3 does not include in tracebacks the code line if it comes from + stdin, which is the case for PrEditor workboxes, so we fake it. This method + will return the line of code at lineNum, from the workbox with the provided + name. + + Args: + name (str): The name of the workbox from which to get a line of code + lineNum (int): The number of the line to return + + Returns: + txt (str): The line of text found + """ + if not self.controller: + return None + workbox = self.controller.workbox_for_name(name) + if not workbox: + return None + if lineNum > workbox.lines(): + return None + txt = workbox.text(lineNum).strip() + "\n" + return txt + + def init_actions(self): + self.uiClearACT = QAction("&Clear", self) + self.uiClearACT.setIcon(QIcon(resourcePath('img/close-thick.png'))) + self.uiClearACT.setToolTip( + "Clears the top section of PrEditor. This does not clear the workbox." + ) + self.uiClearACT.setShortcut(QKeySequence("Ctrl+Shift+Alt+D")) + self.uiClearACT.setShortcutContext( + Qt.ShortcutContext.WidgetWithChildrenShortcut + ) + self.uiClearACT.triggered.connect(self.clear) + self.addAction(self.uiClearACT) + + self.uiAddBreakACT = QAction("Add Separator") + self.uiAddBreakACT.triggered.connect(self.add_separator) + + def init_logging_handlers(self, attrName=None, value=None): + self.logging_info = {} + for h in self.logging_handlers: + hi = HandlerInfo(h) + hi.install(self.write_log) + self.logging_info[hi.name] = hi + + def init_excepthook(self, attrName=None, value=None): + from preditor.excepthooks import PreditorExceptHook + + if value: + if self.write_error not in PreditorExceptHook.callbacks: + PreditorExceptHook.callbacks.append(self.write_error) + else: + if self.write_error in PreditorExceptHook.callbacks: + PreditorExceptHook.callbacks.remove(self.write_error) + + 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()): + self.viewport().setCursor(Qt.CursorShape.PointingHandCursor) + else: + self.viewport().unsetCursor() + 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. + """ + 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.mousePressPos + left = event.button() == Qt.MouseButton.LeftButton + anchor = self.anchorAt(event.pos()) + + if samePos and left and anchor: + self.errorHyperlink(anchor) + self.mousePressPos = None + + QApplication.restoreOverrideCursor() + ret = super().mouseReleaseEvent(event) + + return ret + + @classmethod + def parseErrorHyperLinkInfo(cls, txt): + """Determine if txt is a File-info line from a traceback, and if so, return info + dict. + """ + + ret = None + if not txt.lstrip().startswith("File "): + return ret + + match = cls.traceback_pattern.search(txt) + if match: + filename = match.groupdict().get('filename') + lineNum = match.groupdict().get('lineNum') + fileStart = txt.find(filename) + fileEnd = fileStart + len(filename) + + ret = { + 'filename': filename, + 'fileStart': fileStart, + 'fileEnd': fileEnd, + 'lineNum': lineNum, + } + return ret + + def onFirstShow(self, event) -> bool: + """Run extra code on the first showing of this widget. + + Example override implementation: + + class MyConsole(ConsoleBase): + def onFirstShow(self, event): + if not super().onFirstShow(event): + return False + self.doWork() + return True + + Returns: + bool: Returns True only if this is the first time this widget is + shown. All overrides of this method should return the same. + """ + if not self._first_show: + return False + + # Configure the stream callbacks if enabled + self.update_streams() + + # Redefine highlight variables now that stylesheet may have been updated + self.codeHighlighter().defineHighlightVariables() + + self._first_show = False + return True + + def setConsoleFont(self, font): + """Set the console's font and adjust the tabStopWidth""" + + # Capture the scroll bar's current position (by percentage of max) + origPercent = None + scroll = self.verticalScrollBar() + if scroll.maximum(): + origPercent = Fraction(scroll.value(), scroll.maximum()) + + # Set console and completer popup fonts + self.setFont(font) + self.completer().popup().setFont(font) + + # Set the setTabStopWidth for the console's font + tab_width = 4 + # TODO: Make tab_width a general user setting + workbox = self.controller.current_workbox() + if workbox: + tab_width = workbox.__tab_width__() + fontPixelWidth = QFontMetrics(font).horizontalAdvance(" ") + self.setTabStopDistance(fontPixelWidth * tab_width) + + # Scroll to same relative position where we started + if origPercent is not None: + self.doubleSingleShotSetScrollValue(origPercent) + + def showEvent(self, event): + # Ensure the onFirstShow method is run. + self.onFirstShow(event) + super().showEvent(event) + + def startPrompt(self, prompt): + """create a new command prompt line with the given prompt + + Args: + prompt(str): The prompt to start the line with. If this prompt + is already the only text on the last line this function does nothing. + """ + self.moveCursor(QTextCursor.MoveOperation.End) + + # if this is not already a new line + if self.textCursor().block().text() != prompt: + charFormat = QTextCharFormat() + self.setCurrentCharFormat(charFormat) + + inputstr = prompt + if self.textCursor().block().text(): + inputstr = '\n' + inputstr + + self.insertPlainText(inputstr) + + scroll = self.verticalScrollBar() + maximum = scroll.maximum() + if maximum is not None: + scroll.setValue(maximum) + + def update_context_menu(self, menu): + """Returns the menu to use for right click context.""" + # Note: this menu is built in reverse order for easy insertion + sep = menu.insertSeparator(menu.actions()[0]) + menu.insertAction(sep, self.uiClearACT) + menu.insertAction(sep, self.uiAddBreakACT) + return menu + + def update_streams(self, attrName=None, value=None): + # overload the sys logger and ensure the stream_manager is installed + self.stream_manager = stream.install_to_std() + + needs_callback = self.stream_echo_stdout or self.stream_echo_stderr + if needs_callback: + # Redirect future writes directly to the console, add any previous + # writes to the console and possibly free up the memory consumed by + # previous writes. It's safe to call this repeatedly. + self.stream_manager.add_callback( + self.write, + replay=self.stream_replay, + disable_writes=self.stream_disable_writes, + clear=self.stream_clear, + ) + else: + self.stream_manager.remove_callback(self.write) + + def get_logging_info(self, name): + # Look for a specific rule to handle this logging message + parts = name.split(".") + for i in range(len(parts), 0, -1): + name = ".".join(parts[:i]) + if name in self.logging_info: + return self.logging_info[name] + + # If no logging handler matches the name but we are showing the root + # handler fall back to using the root handler to handle this logging call. + if "root" in self.logging_info: + return self.logging_info["root"] + + # Otherwise ignore it + return None + + def write_error(self, *exc_info): + text = traceback.format_exception(*exc_info) + for line in text: + self.write(line, stream_type=StreamType.CONSOLE | StreamType.STDERR) + + def write_log(self, log_data, stream_type=StreamType.CONSOLE): + """Write a logging message to the console depending on filters.""" + handler, record = log_data + # Find the console configuration that allows processing of this record + logging_info = self.get_logging_info(record.name) + if logging_info is None: + return + + # Only log the record if it matches the logging level requirements + if logging_info.level > record.levelno: + return + + formatter = handler + if logging_info.formatter: + formatter = logging_info.formatter + msg = formatter.format(record) + self.write(f'{msg}\n', stream_type=stream_type) + + def write(self, msg, stream_type=StreamType.STDOUT): + """Write a message to the logger. + + 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. + """ + 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) + + def _write(self, msg, stream_type=StreamType.STDOUT): + """write the message to the logger""" + if not msg: + return + + # Convert the stream_manager's stream to the boolean value this function expects + to_error = stream_type & StreamType.STDERR == StreamType.STDERR + to_console = stream_type & StreamType.CONSOLE == StreamType.CONSOLE + to_result = stream_type & StreamType.RESULT == StreamType.RESULT + + # Check that we haven't been garbage collected before trying to write. + # This can happen while shutting down a QApplication like Nuke. + if not QtCompat.isValid(self): + return + + if to_result and not self.stream_echo_result: + return + + # If stream_type is Console, then always show the output + if not to_console: + # Otherwise only show the message + if to_error and not self.stream_echo_stderr: + return + 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() + if not to_error: + charFormat.setForeground(self.stdoutColor) + else: + charFormat.setForeground(self.errorMessageColor) + self.setCurrentCharFormat(charFormat) + + # If showing Error Hyperlinks... Sometimes (when a syntax error, at least), + # the last File-Info line of a traceback is issued in multiple messages + # starting with unicode paragraph separator (r"\u2029") and followed by a + # newline, so our normal string checks search won't work. Instead, we'll + # manually reconstruct the line. If msg is a newline, grab that current line + # and check it. If it matches,proceed using that line as msg + cursor = self.textCursor() + info = None + + if doHyperlink and msg == '\n': + cursor.select(QTextCursor.SelectionType.BlockUnderCursor) + line = cursor.selectedText() + + # Remove possible leading unicode paragraph separator, which really + # messes up the works + if line and line[0] not in string.printable: + line = line[1:] + + info = self.parseErrorHyperLinkInfo(line) + if info: + cursor.insertText("\n") + msg = "{}\n".format(line) + + # If showing Error Hyperlinks, display underline output, otherwise + # display normal output. Exclude ConsolePrEdits + info = info if info else self.parseErrorHyperLinkInfo(msg) + filename = info.get("filename", "") if info else "" + + # Determine if this is a workbox line of code, or code run directly + # in the console + isWorkbox = '' in filename or '' in filename + isConsolePrEdit = '' in filename + + # Starting in Python 3, tracebacks don't include the code executed + # for stdin, so workbox code won't appear. This attempts to include + # it. There is an exception for SyntaxErrors, which DO include the + # offending line of code, so in those cases (indicated by lack of + # inStr from the regex search) we skip faking the code line. + if isWorkbox: + match = self.workbox_pattern.search(msg) + workboxName = match.groupdict().get("workboxName") + lineNum = int(match.groupdict().get("lineNum")) - 1 + inStr = match.groupdict().get("inStr", "") + + workboxLine = self.getWorkboxLine(workboxName, lineNum) + if workboxLine and inStr: + indent = self.getIndentForCodeTracebackLine(msg) + msg = "{}{}{}".format(msg, indent, workboxLine) + + elif isConsolePrEdit: + # Syntax error tracebacks are different than other Exception. + # 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. + match = self.console_pattern.search(msg) + inStr = match.groupdict().get("inStr", "") + if inStr: + consoleLine = self.consoleLine + 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 = '' + 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) + + # These Qt Properties can be customized using style sheets. + commentColor = QtPropertyInit('_commentColor', QColor(0, 206, 52)) + errorMessageColor = QtPropertyInit('_errorMessageColor', QColor(Qt.GlobalColor.red)) + keywordColor = QtPropertyInit('_keywordColor', QColor(17, 154, 255)) + resultColor = QtPropertyInit('_resultColor', QColor(128, 128, 128)) + stdoutColor = QtPropertyInit('_stdoutColor', QColor(17, 154, 255)) + stringColor = QtPropertyInit('_stringColor', QColor(255, 128, 0)) + + logging_handlers = QtPropertyInit( + '_logging_handlers', list, callback=init_logging_handlers, typ="QStringList" + ) + """Used to install LoggerWindowHandler's for this console. Each item should be a + `handler.name,level`. Level can be the int value (50) or level name (DEBUG). + """ + + # Configure stdout/error redirection options + stream_clear = QtPropertyInit('_stream_clear', False) + """When first shown, should this instance clear the stream manager's stored + history?""" + stream_disable_writes = QtPropertyInit('_stream_disable_writes', False) + """When first shown, should this instance disable writes on the stream?""" + stream_replay = QtPropertyInit('_stream_replay', False) + """When first shown, should this instance replay the streams stored history?""" + stream_echo_stderr = QtPropertyInit( + '_stream_echo_stderr', False, callback=update_streams + ) + """Should this console print stderr writes?""" + stream_echo_stdout = QtPropertyInit( + '_stream_echo_stdout', False, callback=update_streams + ) + """Should this console print stdout writes?""" + stream_echo_result = False + """Reserved for ConsolePrEdit to enable StreamType.RESULT output. There is + no reason for the baseclass to use QtPropertyInit, but this property is + checked used by write so it needs defined.""" + stream_echo_tracebacks = QtPropertyInit( + "_stream_echo_tracebacks", False, callback=init_excepthook + ) + """Should this console print captured exceptions? Only use this if + stream_echo_stderr is disabled or you likely will get duplicate output. + """ + + +# Build and add the class properties for regex patterns so subclasses can use them. +ConsoleBase._ConsoleBase__defineRegexPatterns() diff --git a/preditor/gui/drag_tab_bar.py b/preditor/gui/drag_tab_bar.py index feb5c538..a2c4fa3e 100644 --- a/preditor/gui/drag_tab_bar.py +++ b/preditor/gui/drag_tab_bar.py @@ -19,7 +19,8 @@ from preditor import osystem from ..gui import handleMenuHovered -from . import QtPropertyInit +from ..utils import Truncate +from ..utils.cute import QtPropertyInit class TabStates(IntEnum): @@ -199,7 +200,7 @@ def getColorAndToolTip(self, index): if hasattr(widget, "__last_saved_text__"): last_saved_text = widget.__last_saved_text__() - last_saved_text = window.truncate_text_lines(last_saved_text) + last_saved_text = Truncate(last_saved_text).lines() toolTip += "\nlast_saved_text: \n{}".format(last_saved_text) color = self.bg_color_map.get(state) diff --git a/preditor/gui/errordialog.py b/preditor/gui/errordialog.py index 0322066b..8dbc4d6b 100644 --- a/preditor/gui/errordialog.py +++ b/preditor/gui/errordialog.py @@ -7,7 +7,8 @@ from Qt.QtGui import QColor, QPixmap from .. import __file__ as pfile -from . import Dialog, QtPropertyInit, loadUi +from ..utils.cute import QtPropertyInit +from . import Dialog, loadUi class ErrorDialog(Dialog): diff --git a/preditor/gui/group_tab_widget/__init__.py b/preditor/gui/group_tab_widget/__init__.py index 4ea9dc68..e69de29b 100644 --- a/preditor/gui/group_tab_widget/__init__.py +++ b/preditor/gui/group_tab_widget/__init__.py @@ -1,528 +0,0 @@ -from __future__ import absolute_import - -from pathlib import Path - -from Qt.QtCore import Qt -from Qt.QtWidgets import QHBoxLayout, QMessageBox, QSizePolicy, QToolButton, QWidget - -from ...prefs import VersionTypes, get_backup_version_info -from ..drag_tab_bar import DragTabBar -from ..workbox_text_edit import WorkboxTextEdit -from .grouped_tab_menu import GroupTabMenu -from .grouped_tab_widget import GroupedTabWidget -from .one_tab_widget import OneTabWidget - -DEFAULT_STYLE_SHEET = """ -/* Make the two buttons in the GroupTabWidget take up the - same horizontal space as the GroupedTabWidget's buttons. -GroupTabWidget>QTabBar::tab{ - max-height: 1.5em; -}*/ -/* We have an icon, no need to show the menu indicator */ -#group_tab_widget_menu_btn::menu-indicator{ - width: 0px; -} -/* The GroupedTabWidget has a single button, make it take - the same space as the GroupTabWidget buttons. */ -GroupedTabWidget>QToolButton,GroupTabWidget>QWidget{ - width: 3em; -} -""" - - -class GroupTabWidget(OneTabWidget): - """A QTabWidget where each tab contains another tab widget, allowing users - to group code editors. It has a corner button to add a new tab, and a menu - allowing users to quickly focus on any tab in the entire group. - """ - - def __init__(self, editor_kwargs=None, core_name=None, *args, **kwargs): - super(GroupTabWidget, self).__init__(*args, **kwargs) - DragTabBar.install_tab_widget(self, 'group_tab_widget') - self.editor_kwargs = editor_kwargs - self.editor_cls = WorkboxTextEdit - self.core_name = core_name - self.setStyleSheet(DEFAULT_STYLE_SHEET) - - self.default_title = 'Group01' - - corner = QWidget(self) - lyt = QHBoxLayout(corner) - lyt.setSpacing(0) - lyt.setContentsMargins(0, 5, 0, 0) - - corner.uiNewTabBTN = QToolButton(corner) - corner.uiNewTabBTN.setObjectName('group_tab_widget_new_btn') - corner.uiNewTabBTN.setText('+') - corner.uiNewTabBTN.released.connect(lambda: self.add_new_tab(None)) - - lyt.addWidget(corner.uiNewTabBTN) - - corner.uiMenuBTN = QToolButton(corner) - corner.uiMenuBTN.setText('\u2630') - corner.uiMenuBTN.setObjectName('group_tab_widget_menu_btn') - corner.uiMenuBTN.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - corner.uiCornerMENU = GroupTabMenu(self, parent=corner.uiMenuBTN) - corner.uiMenuBTN.setMenu(corner.uiCornerMENU) - - self.adjustSizePolicy(corner) - self.adjustSizePolicy(corner.uiNewTabBTN) - self.adjustSizePolicy(corner.uiMenuBTN) - self.adjustSizePolicy(corner.uiCornerMENU) - - lyt.addWidget(corner.uiMenuBTN) - - self.uiCornerBTN = corner - self.setCornerWidget(self.uiCornerBTN, Qt.Corner.TopRightCorner) - - def adjustSizePolicy(self, button): - sp = button.sizePolicy() - sp.setVerticalPolicy(QSizePolicy.Policy.Preferred) - button.setSizePolicy(sp) - - def add_new_tab(self, group, title=None, prefs=None): - """Adds a new tab to the requested group, creating the group if the group - doesn't exist. - - Args: - group: The group to add a new tab to. This can be an int index of an - existing tab, or the name of the group and it will create the group - if needed. If None is passed it will add a new tab `Group {last+1}`. - If True is passed, then the current group tab is used. - title (str, optional): The name to give the newly created tab inside - the group. - - Returns: - GroupedTabWidget: The tab group for this group. - WorkboxMixin: The new text editor. - """ - if not group: - group = self.get_next_available_tab_name() - elif group is True: - group = self.currentIndex() - - parent = None - if isinstance(group, int): - group_title = self.tabText(group) - parent = self.widget(group) - elif isinstance(group, str): - group_title = group - index = self.index_for_text(group) - if index != -1: - parent = self.widget(index) - - if not parent: - parent, group_title = self.default_tab(group_title, prefs) - self.addTab(parent, group_title) - - # Create the first editor tab and make it visible - editor = parent.add_new_editor(title, prefs) - self.setCurrentIndex(self.indexOf(parent)) - self.window().focusToWorkbox() - self.tabBar().setFont(self.window().font()) - return parent, editor - - def all_widgets(self): - """A generator yielding information about every widget under every group. - - Yields: - widget, group tab name, widget tab name, group tab index, widget tab index - """ - for group_index in range(self.count()): - group_name = self.tabText(group_index) - - tab_widget = self.widget(group_index) - for tab_index in range(tab_widget.count()): - tab_name = tab_widget.tabText(tab_index) - yield tab_widget.widget( - tab_index - ), group_name, tab_name, group_index, tab_index - - def close_current_tab(self): - """Convenient method to close the currently open editor tab prompting - the user to confirm closing.""" - editor_tab = self.currentWidget() - editor_tab.close_tab(editor_tab.currentIndex()) - - def close_tab(self, index): - ret = QMessageBox.question( - self, - 'Close all editors under this tab?', - 'Are you sure you want to close all tabs under the "{}" tab?'.format( - self.tabText(index) - ), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, - ) - if ret == QMessageBox.StandardButton.Yes: - self.store_closed_workboxes(index) - super(GroupTabWidget, self).close_tab(index) - - def store_closed_workboxes(self, index): - """Store all the workbox names in group tab being closed. - - Args: - index (int): The index of the group being closed - """ - group = self.widget(index) - - for idx in range(group.count()): - workbox = group.widget(idx) - - # Save the workbox first, so we can possibly restore it later. - workbox.__save_prefs__(saveLinkedFile=False) - - self.parent().window().addRecentlyClosedWorkbox(workbox) - - def current_groups_widget(self): - """Returns the current widget of the currently selected group or None.""" - editor_tab = self.currentWidget() - if editor_tab: - return editor_tab.currentWidget() - - def default_tab(self, title=None, prefs=None): - title = title or self.default_title - widget = GroupedTabWidget( - parent=self, - editor_kwargs=self.editor_kwargs, - editor_cls=self.editor_cls, - core_name=self.core_name, - ) - return widget, title - - def get_next_available_tab_name(self, name=None): - """Get the next available tab name, providing a default if needed. - - Args: - name (str, optional): The name for which to get the next available - name. - - Returns: - str: The determined next available tab name - """ - if name is None: - name = self.default_title - return super().get_next_available_tab_name(name) - - def append_orphan_workboxes_to_prefs(self, prefs, existing_by_group): - """If prefs are saved in a different PrEditor instance (in this same core) - there may be a workbox which is either: - - new in this instance - - removed in the saved other instance - Any of these workboxes are 'orphaned'. Rather than just deleting it, we - alert the user, so that work can be saved. - - We also add any orphan workboxes to the window's boxesOrphanedViaInstance - dict, in the form `workbox_id: workbox`. - - Args: - prefs (dict): The 'workboxes' section of the PrEditor prefs - existing_by_group (dict): The existing workbox's info (as returned - by self.all_widgets(), by group. - - Returns: - prefs (dict): The 'workboxes' section of the PrEditor prefs, updated - """ - groups = prefs.get("groups") - for group_name, workbox_infos in existing_by_group.items(): - prefs_group = None - for temp_group in groups: - temp_name = temp_group.get("name") - if temp_name == group_name: - prefs_group = temp_group - break - - # If the orphan's group doesn't yet exist, we prepare to make it - new_group = None - if not prefs_group: - new_group = dict(name=group_name, tabs=[]) - - cur_group = prefs_group or new_group - cur_tabs = cur_group.get("tabs") - - for workbox_info in workbox_infos: - # Create workbox_dict - workbox = workbox_info[0] - name = workbox_info[2] - - workbox_id = workbox.__workbox_id__() - - workbox_dict = dict( - name=name, - workbox_id=workbox_id, - filename=workbox.__filename__(), - backup_file=workbox.__backup_file__(), - orphaned_by_instance=True, - ) - - self.window().boxesOrphanedViaInstance[workbox_id] = workbox - - cur_tabs.append(workbox_dict) - if new_group: - groups.append(cur_group) - return prefs - - def restore_prefs(self, prefs): - """Adds tab groups and tabs, restoring the selected tabs. If a tab is - linked to a file that no longer exists, will not be added. Restores the - current tab for each group and the current group of tabs. If a current - tab is no longer valid, it will default to the first tab. - - Preference schema: - ```json - { - "groups": [ - { - // Name of the group tab. [Required] - "name": "My Group", - // This group should be the active group. First in list wins. - "current": true, - "tabs": [ - { - // If filename is not null, this file is loaded - "filename": "C:\\temp\\invalid_asdfdfd.py", - // Name of the editor's tab [Optional] - "name": "invalid_asdfdfd.py", - "workbox_id": null - }, - { - // This tab should be active for the group. - "current": true, - "filename": null, - "name": "Workbox", - // If workbox_id is not null, this file is loaded. - // Ignored if filename is not null. - "workbox_id": "workbox_2yrwctco_a.py" - } - ] - } - ] - } - ``` - """ - selected_workbox_id = None - current_workbox = self.window().current_workbox() - if current_workbox: - selected_workbox_id = current_workbox.__workbox_id__() - - # When re-running restore_prefs (ie after another instance saved - # workboxes, and we are reloading them here, get the workbox_ids of all - # workboxes defined in prefs - pref_workbox_ids = [] - for group in prefs.get('groups', []): - for tab in group.get('tabs', []): - pref_workbox_ids.append(tab.get("workbox_id", None)) - - # Collect data about workboxes which already exist (if we are re-running - # this method after workboxes exist, ie another PrEditor instance has - # changed contents and we are now matching those changes. - existing_by_id = {} - existing_by_group = {} - for workbox_info in list(self.all_widgets()): - workbox = workbox_info[0] - workbox_id = workbox.__workbox_id__() - group_name = workbox_info[1] - existing_by_id[workbox.__workbox_id__()] = workbox_info - - # If we had a workbox, but what we are about to load doesn't include - # it, add it back in so it will be shown. - if workbox_id not in pref_workbox_ids: - existing_by_group.setdefault(group_name, []).append(workbox_info) - - prefs = self.append_orphan_workboxes_to_prefs(prefs, existing_by_group) - - self.clear() - - current_group = None - workboxes_missing_id = [] - for group in prefs.get('groups', []): - current_tab = None - tab_widget = None - - group_name = group['name'] - group_name = self.get_next_available_tab_name(group_name) - - for tab in group.get('tabs', []): - # Only add this tab if, there is data on disk to load. The user can - # open multiple instances of PrEditor using the same prefs. The - # json pref data represents the last time the prefs were saved. - # Each editor's contents are saved to individual files on disk. - # When a editor tab is closed, the temp file is removed, not on - # preferences save. - # By not restoring tabs for deleted files we prevent accidentally - # restoring a tab with empty text. - - loadable = False - name = tab['name'] - - # Support legacy arg for emergency backwards compatibility - tempfile = tab.get('tempfile', None) - # Get various possible saved filepaths. - filename = tab.get('filename', "") - if filename: - if Path(filename).is_file(): - loadable = True - - workbox_id = tab.get('workbox_id', None) - # If user went back to before PrEditor used workbox_id, and - # back, the workbox may not be loadable. First, try to recover - # it from the backup_file. If not recoverable, collect and - # notify user. - if workbox_id is None: - bak_file = tab.get('backup_file', None) - if bak_file: - workbox_id = str(Path(bak_file).parent) - elif not tempfile: - missing_name = f"{group_name}/{name}" - workboxes_missing_id.append(missing_name) - continue - - orphaned_by_instance = tab.get('orphaned_by_instance', False) - - # See if there are any workbox backups available - backup_file, _, count = get_backup_version_info( - self.window().name, workbox_id, VersionTypes.Last, "" - ) - if count: - loadable = True - if not loadable: - continue - - # There is a file on disk, add the tab, creating the group - # tab if it hasn't already been created. - prefs = dict( - workbox_id=workbox_id, - filename=filename, - backup_file=backup_file, - existing_editor_info=existing_by_id.pop(workbox_id, None), - orphaned_by_instance=orphaned_by_instance, - tempfile=tempfile, - ) - tab_widget, editor = self.add_new_tab( - group_name, title=name, prefs=prefs - ) - - editor.__set_last_workbox_name__(editor.__workbox_name__()) - editor.__determine_been_changed_by_instance__() - - # If more than one tab in this group is listed as current, only - # respect the first - if current_tab is None and tab.get('current'): - current_tab = tab_widget.indexOf(editor) - - # If there were no files to load, this tab was not added and there - # we don't need to restore the current tab for this group - if tab_widget is None: - continue - - # Restore the current tab for this group - if current_tab is None: - # If there is no longer a current tab, default to the first tab - current_tab = 0 - tab_widget.setCurrentIndex(current_tab) - - # Which tab group is the active one? If more than one tab in this - # group is listed as current, only respect the first. - if current_group is None and group.get('current'): - current_group = self.indexOf(tab_widget) - - if selected_workbox_id: - for widget_info in self.all_widgets(): - widget, _, _, group_idx, tab_idx = widget_info - if widget.__workbox_id__() == selected_workbox_id: - self.setCurrentIndex(group_idx) - grouped = self.widget(group_idx) - grouped.setCurrentIndex(tab_idx) - break - - # If any workboxes could not be loaded because they had no stored - # workbox_id, notify user. This likely only happens if user goes back - # to older PrEditor, and back. - if workboxes_missing_id: - suffix = "" if len(workboxes_missing_id) == 1 else "es" - workboxes_missing_id.insert(0, "") - missing_names = "\n\t".join(workboxes_missing_id) - msg = ( - f"The following workbox{suffix} somehow did not have a " - f"workbox_id stored, and therefore could not be loaded:" - f"{missing_names}" - ) - print(msg) - - # Restore the current group for this widget - if current_group is None: - # If there is no longer a current tab, default to the first tab - current_group = 0 - self.setCurrentIndex(current_group) - - def save_prefs(self, prefs=None): - groups = [] - if prefs is None: - prefs = {} - - prefs['groups'] = groups - current_group = self.currentIndex() - for i in range(self.count()): - tabs = [] - group = {} - # Hopefully the alphabetical sorting of this dict is preserved in py3 - # to make it easy to diff the json pref file if ever required. - if i == current_group: - group['current'] = True - group['name'] = self.tabText(i) - group['tabs'] = tabs - - tab_widget = self.widget(i) - current_editor = tab_widget.currentIndex() - for j in range(tab_widget.count()): - current = True if j == current_editor else None - workbox = tab_widget.widget(j) - tabs.append(workbox.__save_prefs__(current=current)) - - groups.append(group) - - return prefs - - def set_current_groups_from_index(self, group, editor): - """Make the specified indexes the current widget and return it. If the - indexes are out of range the current widget is not changed. - - Args: - group (int): The index of the group tab to make current. - editor (int): The index of the editor under the group tab to - make current. - - Returns: - QWidget: The current widget after applying. - """ - self.setCurrentIndex(group) - tab_widget = self.currentWidget() - tab_widget.setCurrentIndex(editor) - return tab_widget.currentWidget() - - def set_current_groups_from_workbox(self, workbox): - """Make the specified workbox the current widget. If the workbox is not - found, the current widget is not changed. - - Args: - workbox (WorkboxMixin): The workbox to make current. - - Returns: - success (bool): Whether the workbox was found and made the current - widget - """ - workbox_infos = self.all_widgets() - found_info = None - for workbox_info in workbox_infos: - if workbox_info[0] == workbox: - found_info = workbox_info - break - if found_info: - workbox = workbox_info[0] - group_idx = workbox_info[-2] - editor_idx = workbox_info[-1] - - self.setCurrentIndex(group_idx) - tab_widget = self.currentWidget() - tab_widget.setCurrentIndex(editor_idx) - - return bool(found_info) diff --git a/preditor/gui/group_tab_widget/group_tab_widget.py b/preditor/gui/group_tab_widget/group_tab_widget.py new file mode 100644 index 00000000..4ea9dc68 --- /dev/null +++ b/preditor/gui/group_tab_widget/group_tab_widget.py @@ -0,0 +1,528 @@ +from __future__ import absolute_import + +from pathlib import Path + +from Qt.QtCore import Qt +from Qt.QtWidgets import QHBoxLayout, QMessageBox, QSizePolicy, QToolButton, QWidget + +from ...prefs import VersionTypes, get_backup_version_info +from ..drag_tab_bar import DragTabBar +from ..workbox_text_edit import WorkboxTextEdit +from .grouped_tab_menu import GroupTabMenu +from .grouped_tab_widget import GroupedTabWidget +from .one_tab_widget import OneTabWidget + +DEFAULT_STYLE_SHEET = """ +/* Make the two buttons in the GroupTabWidget take up the + same horizontal space as the GroupedTabWidget's buttons. +GroupTabWidget>QTabBar::tab{ + max-height: 1.5em; +}*/ +/* We have an icon, no need to show the menu indicator */ +#group_tab_widget_menu_btn::menu-indicator{ + width: 0px; +} +/* The GroupedTabWidget has a single button, make it take + the same space as the GroupTabWidget buttons. */ +GroupedTabWidget>QToolButton,GroupTabWidget>QWidget{ + width: 3em; +} +""" + + +class GroupTabWidget(OneTabWidget): + """A QTabWidget where each tab contains another tab widget, allowing users + to group code editors. It has a corner button to add a new tab, and a menu + allowing users to quickly focus on any tab in the entire group. + """ + + def __init__(self, editor_kwargs=None, core_name=None, *args, **kwargs): + super(GroupTabWidget, self).__init__(*args, **kwargs) + DragTabBar.install_tab_widget(self, 'group_tab_widget') + self.editor_kwargs = editor_kwargs + self.editor_cls = WorkboxTextEdit + self.core_name = core_name + self.setStyleSheet(DEFAULT_STYLE_SHEET) + + self.default_title = 'Group01' + + corner = QWidget(self) + lyt = QHBoxLayout(corner) + lyt.setSpacing(0) + lyt.setContentsMargins(0, 5, 0, 0) + + corner.uiNewTabBTN = QToolButton(corner) + corner.uiNewTabBTN.setObjectName('group_tab_widget_new_btn') + corner.uiNewTabBTN.setText('+') + corner.uiNewTabBTN.released.connect(lambda: self.add_new_tab(None)) + + lyt.addWidget(corner.uiNewTabBTN) + + corner.uiMenuBTN = QToolButton(corner) + corner.uiMenuBTN.setText('\u2630') + corner.uiMenuBTN.setObjectName('group_tab_widget_menu_btn') + corner.uiMenuBTN.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + corner.uiCornerMENU = GroupTabMenu(self, parent=corner.uiMenuBTN) + corner.uiMenuBTN.setMenu(corner.uiCornerMENU) + + self.adjustSizePolicy(corner) + self.adjustSizePolicy(corner.uiNewTabBTN) + self.adjustSizePolicy(corner.uiMenuBTN) + self.adjustSizePolicy(corner.uiCornerMENU) + + lyt.addWidget(corner.uiMenuBTN) + + self.uiCornerBTN = corner + self.setCornerWidget(self.uiCornerBTN, Qt.Corner.TopRightCorner) + + def adjustSizePolicy(self, button): + sp = button.sizePolicy() + sp.setVerticalPolicy(QSizePolicy.Policy.Preferred) + button.setSizePolicy(sp) + + def add_new_tab(self, group, title=None, prefs=None): + """Adds a new tab to the requested group, creating the group if the group + doesn't exist. + + Args: + group: The group to add a new tab to. This can be an int index of an + existing tab, or the name of the group and it will create the group + if needed. If None is passed it will add a new tab `Group {last+1}`. + If True is passed, then the current group tab is used. + title (str, optional): The name to give the newly created tab inside + the group. + + Returns: + GroupedTabWidget: The tab group for this group. + WorkboxMixin: The new text editor. + """ + if not group: + group = self.get_next_available_tab_name() + elif group is True: + group = self.currentIndex() + + parent = None + if isinstance(group, int): + group_title = self.tabText(group) + parent = self.widget(group) + elif isinstance(group, str): + group_title = group + index = self.index_for_text(group) + if index != -1: + parent = self.widget(index) + + if not parent: + parent, group_title = self.default_tab(group_title, prefs) + self.addTab(parent, group_title) + + # Create the first editor tab and make it visible + editor = parent.add_new_editor(title, prefs) + self.setCurrentIndex(self.indexOf(parent)) + self.window().focusToWorkbox() + self.tabBar().setFont(self.window().font()) + return parent, editor + + def all_widgets(self): + """A generator yielding information about every widget under every group. + + Yields: + widget, group tab name, widget tab name, group tab index, widget tab index + """ + for group_index in range(self.count()): + group_name = self.tabText(group_index) + + tab_widget = self.widget(group_index) + for tab_index in range(tab_widget.count()): + tab_name = tab_widget.tabText(tab_index) + yield tab_widget.widget( + tab_index + ), group_name, tab_name, group_index, tab_index + + def close_current_tab(self): + """Convenient method to close the currently open editor tab prompting + the user to confirm closing.""" + editor_tab = self.currentWidget() + editor_tab.close_tab(editor_tab.currentIndex()) + + def close_tab(self, index): + ret = QMessageBox.question( + self, + 'Close all editors under this tab?', + 'Are you sure you want to close all tabs under the "{}" tab?'.format( + self.tabText(index) + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + ) + if ret == QMessageBox.StandardButton.Yes: + self.store_closed_workboxes(index) + super(GroupTabWidget, self).close_tab(index) + + def store_closed_workboxes(self, index): + """Store all the workbox names in group tab being closed. + + Args: + index (int): The index of the group being closed + """ + group = self.widget(index) + + for idx in range(group.count()): + workbox = group.widget(idx) + + # Save the workbox first, so we can possibly restore it later. + workbox.__save_prefs__(saveLinkedFile=False) + + self.parent().window().addRecentlyClosedWorkbox(workbox) + + def current_groups_widget(self): + """Returns the current widget of the currently selected group or None.""" + editor_tab = self.currentWidget() + if editor_tab: + return editor_tab.currentWidget() + + def default_tab(self, title=None, prefs=None): + title = title or self.default_title + widget = GroupedTabWidget( + parent=self, + editor_kwargs=self.editor_kwargs, + editor_cls=self.editor_cls, + core_name=self.core_name, + ) + return widget, title + + def get_next_available_tab_name(self, name=None): + """Get the next available tab name, providing a default if needed. + + Args: + name (str, optional): The name for which to get the next available + name. + + Returns: + str: The determined next available tab name + """ + if name is None: + name = self.default_title + return super().get_next_available_tab_name(name) + + def append_orphan_workboxes_to_prefs(self, prefs, existing_by_group): + """If prefs are saved in a different PrEditor instance (in this same core) + there may be a workbox which is either: + - new in this instance + - removed in the saved other instance + Any of these workboxes are 'orphaned'. Rather than just deleting it, we + alert the user, so that work can be saved. + + We also add any orphan workboxes to the window's boxesOrphanedViaInstance + dict, in the form `workbox_id: workbox`. + + Args: + prefs (dict): The 'workboxes' section of the PrEditor prefs + existing_by_group (dict): The existing workbox's info (as returned + by self.all_widgets(), by group. + + Returns: + prefs (dict): The 'workboxes' section of the PrEditor prefs, updated + """ + groups = prefs.get("groups") + for group_name, workbox_infos in existing_by_group.items(): + prefs_group = None + for temp_group in groups: + temp_name = temp_group.get("name") + if temp_name == group_name: + prefs_group = temp_group + break + + # If the orphan's group doesn't yet exist, we prepare to make it + new_group = None + if not prefs_group: + new_group = dict(name=group_name, tabs=[]) + + cur_group = prefs_group or new_group + cur_tabs = cur_group.get("tabs") + + for workbox_info in workbox_infos: + # Create workbox_dict + workbox = workbox_info[0] + name = workbox_info[2] + + workbox_id = workbox.__workbox_id__() + + workbox_dict = dict( + name=name, + workbox_id=workbox_id, + filename=workbox.__filename__(), + backup_file=workbox.__backup_file__(), + orphaned_by_instance=True, + ) + + self.window().boxesOrphanedViaInstance[workbox_id] = workbox + + cur_tabs.append(workbox_dict) + if new_group: + groups.append(cur_group) + return prefs + + def restore_prefs(self, prefs): + """Adds tab groups and tabs, restoring the selected tabs. If a tab is + linked to a file that no longer exists, will not be added. Restores the + current tab for each group and the current group of tabs. If a current + tab is no longer valid, it will default to the first tab. + + Preference schema: + ```json + { + "groups": [ + { + // Name of the group tab. [Required] + "name": "My Group", + // This group should be the active group. First in list wins. + "current": true, + "tabs": [ + { + // If filename is not null, this file is loaded + "filename": "C:\\temp\\invalid_asdfdfd.py", + // Name of the editor's tab [Optional] + "name": "invalid_asdfdfd.py", + "workbox_id": null + }, + { + // This tab should be active for the group. + "current": true, + "filename": null, + "name": "Workbox", + // If workbox_id is not null, this file is loaded. + // Ignored if filename is not null. + "workbox_id": "workbox_2yrwctco_a.py" + } + ] + } + ] + } + ``` + """ + selected_workbox_id = None + current_workbox = self.window().current_workbox() + if current_workbox: + selected_workbox_id = current_workbox.__workbox_id__() + + # When re-running restore_prefs (ie after another instance saved + # workboxes, and we are reloading them here, get the workbox_ids of all + # workboxes defined in prefs + pref_workbox_ids = [] + for group in prefs.get('groups', []): + for tab in group.get('tabs', []): + pref_workbox_ids.append(tab.get("workbox_id", None)) + + # Collect data about workboxes which already exist (if we are re-running + # this method after workboxes exist, ie another PrEditor instance has + # changed contents and we are now matching those changes. + existing_by_id = {} + existing_by_group = {} + for workbox_info in list(self.all_widgets()): + workbox = workbox_info[0] + workbox_id = workbox.__workbox_id__() + group_name = workbox_info[1] + existing_by_id[workbox.__workbox_id__()] = workbox_info + + # If we had a workbox, but what we are about to load doesn't include + # it, add it back in so it will be shown. + if workbox_id not in pref_workbox_ids: + existing_by_group.setdefault(group_name, []).append(workbox_info) + + prefs = self.append_orphan_workboxes_to_prefs(prefs, existing_by_group) + + self.clear() + + current_group = None + workboxes_missing_id = [] + for group in prefs.get('groups', []): + current_tab = None + tab_widget = None + + group_name = group['name'] + group_name = self.get_next_available_tab_name(group_name) + + for tab in group.get('tabs', []): + # Only add this tab if, there is data on disk to load. The user can + # open multiple instances of PrEditor using the same prefs. The + # json pref data represents the last time the prefs were saved. + # Each editor's contents are saved to individual files on disk. + # When a editor tab is closed, the temp file is removed, not on + # preferences save. + # By not restoring tabs for deleted files we prevent accidentally + # restoring a tab with empty text. + + loadable = False + name = tab['name'] + + # Support legacy arg for emergency backwards compatibility + tempfile = tab.get('tempfile', None) + # Get various possible saved filepaths. + filename = tab.get('filename', "") + if filename: + if Path(filename).is_file(): + loadable = True + + workbox_id = tab.get('workbox_id', None) + # If user went back to before PrEditor used workbox_id, and + # back, the workbox may not be loadable. First, try to recover + # it from the backup_file. If not recoverable, collect and + # notify user. + if workbox_id is None: + bak_file = tab.get('backup_file', None) + if bak_file: + workbox_id = str(Path(bak_file).parent) + elif not tempfile: + missing_name = f"{group_name}/{name}" + workboxes_missing_id.append(missing_name) + continue + + orphaned_by_instance = tab.get('orphaned_by_instance', False) + + # See if there are any workbox backups available + backup_file, _, count = get_backup_version_info( + self.window().name, workbox_id, VersionTypes.Last, "" + ) + if count: + loadable = True + if not loadable: + continue + + # There is a file on disk, add the tab, creating the group + # tab if it hasn't already been created. + prefs = dict( + workbox_id=workbox_id, + filename=filename, + backup_file=backup_file, + existing_editor_info=existing_by_id.pop(workbox_id, None), + orphaned_by_instance=orphaned_by_instance, + tempfile=tempfile, + ) + tab_widget, editor = self.add_new_tab( + group_name, title=name, prefs=prefs + ) + + editor.__set_last_workbox_name__(editor.__workbox_name__()) + editor.__determine_been_changed_by_instance__() + + # If more than one tab in this group is listed as current, only + # respect the first + if current_tab is None and tab.get('current'): + current_tab = tab_widget.indexOf(editor) + + # If there were no files to load, this tab was not added and there + # we don't need to restore the current tab for this group + if tab_widget is None: + continue + + # Restore the current tab for this group + if current_tab is None: + # If there is no longer a current tab, default to the first tab + current_tab = 0 + tab_widget.setCurrentIndex(current_tab) + + # Which tab group is the active one? If more than one tab in this + # group is listed as current, only respect the first. + if current_group is None and group.get('current'): + current_group = self.indexOf(tab_widget) + + if selected_workbox_id: + for widget_info in self.all_widgets(): + widget, _, _, group_idx, tab_idx = widget_info + if widget.__workbox_id__() == selected_workbox_id: + self.setCurrentIndex(group_idx) + grouped = self.widget(group_idx) + grouped.setCurrentIndex(tab_idx) + break + + # If any workboxes could not be loaded because they had no stored + # workbox_id, notify user. This likely only happens if user goes back + # to older PrEditor, and back. + if workboxes_missing_id: + suffix = "" if len(workboxes_missing_id) == 1 else "es" + workboxes_missing_id.insert(0, "") + missing_names = "\n\t".join(workboxes_missing_id) + msg = ( + f"The following workbox{suffix} somehow did not have a " + f"workbox_id stored, and therefore could not be loaded:" + f"{missing_names}" + ) + print(msg) + + # Restore the current group for this widget + if current_group is None: + # If there is no longer a current tab, default to the first tab + current_group = 0 + self.setCurrentIndex(current_group) + + def save_prefs(self, prefs=None): + groups = [] + if prefs is None: + prefs = {} + + prefs['groups'] = groups + current_group = self.currentIndex() + for i in range(self.count()): + tabs = [] + group = {} + # Hopefully the alphabetical sorting of this dict is preserved in py3 + # to make it easy to diff the json pref file if ever required. + if i == current_group: + group['current'] = True + group['name'] = self.tabText(i) + group['tabs'] = tabs + + tab_widget = self.widget(i) + current_editor = tab_widget.currentIndex() + for j in range(tab_widget.count()): + current = True if j == current_editor else None + workbox = tab_widget.widget(j) + tabs.append(workbox.__save_prefs__(current=current)) + + groups.append(group) + + return prefs + + def set_current_groups_from_index(self, group, editor): + """Make the specified indexes the current widget and return it. If the + indexes are out of range the current widget is not changed. + + Args: + group (int): The index of the group tab to make current. + editor (int): The index of the editor under the group tab to + make current. + + Returns: + QWidget: The current widget after applying. + """ + self.setCurrentIndex(group) + tab_widget = self.currentWidget() + tab_widget.setCurrentIndex(editor) + return tab_widget.currentWidget() + + def set_current_groups_from_workbox(self, workbox): + """Make the specified workbox the current widget. If the workbox is not + found, the current widget is not changed. + + Args: + workbox (WorkboxMixin): The workbox to make current. + + Returns: + success (bool): Whether the workbox was found and made the current + widget + """ + workbox_infos = self.all_widgets() + found_info = None + for workbox_info in workbox_infos: + if workbox_info[0] == workbox: + found_info = workbox_info + break + if found_info: + workbox = workbox_info[0] + group_idx = workbox_info[-2] + editor_idx = workbox_info[-1] + + self.setCurrentIndex(group_idx) + tab_widget = self.currentWidget() + tab_widget.setCurrentIndex(editor_idx) + + return bool(found_info) diff --git a/preditor/gui/logger_window_handler.py b/preditor/gui/logger_window_handler.py index 0a55f56a..e619b18f 100644 --- a/preditor/gui/logger_window_handler.py +++ b/preditor/gui/logger_window_handler.py @@ -3,46 +3,75 @@ import logging from .. import instance +from ..constants import StreamType +from ..stream import Director, Manager class LoggerWindowHandler(logging.Handler): """A logging handler that writes directly to the PrEditor instance. Args: - error (bool, optional): Write the output as if it were written - to sys.stderr and not sys.stdout. Ie in red text. formatter (str or logging.Formatter, optional): If specified, this is passed to setFormatter. + stream (optional): If provided write to this stream instead of the + main preditor instance's console. + stream_type (StreamType, optional): If not None, pass this value to the + write call's force kwarg. """ default_format = ( '%(levelname)s %(module)s.%(funcName)s line:%(lineno)d - %(message)s' ) - def __init__(self, error=True, formatter=default_format): + def __init__( + self, + formatter=default_format, + stream=None, + stream_type=StreamType.STDERR | StreamType.CONSOLE, + ): super(LoggerWindowHandler, self).__init__() - self.error = error + self.stream_type = stream_type + self.stream = stream + self.manager = Manager() + self.director = Director(self.manager, StreamType.CONSOLE) + if formatter is not None: if not isinstance(formatter, logging.Formatter): formatter = logging.Formatter(formatter) self.setFormatter(formatter) def emit(self, record): - _instance = instance(create=False) - if _instance is None: - # No gui has been created yet, so nothing to do - return try: - # If the python logger was closed and garbage collected, - # there is nothing to do, simply exit the call - console = _instance.console() - if not console: + # If no gui has been created yet, or the `preditor.instance()` was + # closed and garbage collected, there is nothing to do, simply exit + stream = self.stream + if not stream: return - msg = self.format(record) - msg = u'{}\n'.format(msg) - console.write(msg, self.error) + kwargs = {} + if self.stream_type is not None: + kwargs["stream_type"] = self.stream_type + stream.write(f'{msg}\n', **kwargs) except (KeyboardInterrupt, SystemExit): raise except Exception: self.handleError(record) + + @property + def stream(self): + """The stream to write log messages to. + + If no stream is set then it returns `preditor.instance().console()`. + """ + if self._stream is not None: + return self._stream + + _instance = instance(create=False) + if _instance is None: + return None + # Note: This does not handle if the instance has been closed + return _instance.console() + + @stream.setter + def stream(self, stream): + self._stream = stream diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 28d32c29..5126aacb 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 Json, stylesheets +from ..utils import Json, Truncate, stylesheets from .completer import CompleterMode from .level_buttons import LoggingLevelButton from .set_text_editor_path_dialog import SetTextEditorPathDialog @@ -360,6 +360,7 @@ def setIcons(self): def createActions(self): """Create the necessary actions""" self.addAction(self.uiClearLogACT) + self.uiConsoleTXT.removeAction(self.uiConsoleTXT.uiClearACT) # Setup ability to cycle completer mode, and create action for each mode self.completerModeCycle = itertools.cycle(CompleterMode) @@ -451,6 +452,22 @@ def setPromptOnLinkedChange(self, state): """ self.uiPromptOnLinkedChangeCHK.setChecked(state) + def launch(self, focus=True): + """Ensure this window is raised to the top and make it regain focus. + + Args: + focus (bool, optional): If True then make sure the console has focus. + """ + self.show() + self.activateWindow() + self.raise_() + self.setWindowState( + self.windowState() & ~Qt.WindowState.WindowMinimized + | Qt.WindowState.WindowActive + ) + if focus: + self.focusToConsole() + def loadPlugins(self): """Load any plugins that modify the LoggerWindow.""" self.plugins = {} @@ -1756,26 +1773,6 @@ def restoreToolbars(self, pref=None): state = QByteArray.fromHex(bytes(state, 'utf-8')) self.restoreState(state) - def truncate_text_lines(self, text, max_text_lines=20): - """Limit input text to a given number of lines - - Args: - text (str): The text to truncate - max_text_lines (int, optional): How many lines to limit text to, - defaults to 20 - - Returns: - truncated (str): The text truncated to the given number of lines - """ - lines = text.split("\n") - orig_len = len(lines) - lines = lines[:max_text_lines] - trim_len = len(lines) - if orig_len != trim_len: - lines.append("...") - truncated = "\n".join(lines) - return truncated - def addRecentlyClosedWorkbox(self, workbox): """Add the name of a recently closed workbox to the Recently Closed Workboxes menu, and add a section of it's text as a tooltip. Also, add @@ -1798,7 +1795,7 @@ def addRecentlyClosedWorkbox(self, workbox): # Disable file monitoring workbox.__set_file_monitoring_enabled__(False) # Add a portion of the text so user can understand what is in each box - text_sample = self.truncate_text_lines(workbox.__text__()) + text_sample = Truncate(workbox.__text__()).lines() # Collect all the info for this workbox workboxDatum = dict( diff --git a/preditor/gui/output_console.py b/preditor/gui/output_console.py new file mode 100644 index 00000000..30eea7e1 --- /dev/null +++ b/preditor/gui/output_console.py @@ -0,0 +1,5 @@ +from .console_base import ConsoleBase + + +class OutputConsole(ConsoleBase): + """A text widget used to show stdout/stderr writes.""" diff --git a/preditor/gui/qtdesigner/__init__.py b/preditor/gui/qtdesigner/__init__.py new file mode 100644 index 00000000..668d4a63 --- /dev/null +++ b/preditor/gui/qtdesigner/__init__.py @@ -0,0 +1,21 @@ +""" +PYSIDE_DESIGNER_PLUGINS or PYQTDESIGNERPATH +""" +__all__ = ["QPyDesignerCustomWidgetCollection", "QPyDesignerCustomWidgetPlugin"] + +import Qt # noqa: E402 + +if Qt.IsPySide6: + from PySide6.QtDesigner import ( + QPyDesignerCustomWidgetCollection, + QPyDesignerCustomWidgetPlugin, + ) +elif Qt.IsPyQt6: + from PyQt6.QtDesigner import QPyDesignerCustomWidgetPlugin +elif Qt.IsPySide2: + from PySide2.QtDesigner import ( + QPyDesignerCustomWidgetCollection, + QPyDesignerCustomWidgetPlugin, + ) +elif Qt.IsPyQt5: + from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin diff --git a/preditor/gui/qtdesigner/_log_plugin.py b/preditor/gui/qtdesigner/_log_plugin.py new file mode 100644 index 00000000..cdfa4277 --- /dev/null +++ b/preditor/gui/qtdesigner/_log_plugin.py @@ -0,0 +1,29 @@ +"""Plugin used to enable access to python's stdout/stderr. Without this we have +no way to see python output on windows because its not a console app. + +All output is written to $TEMP/preditor_qdesigner_plugins.log. +""" + +import sys +import tempfile +import traceback +from pathlib import Path + +from preditor.debug import logToFile + +path = Path(tempfile.gettempdir()) / "preditor_qdesigner_plugins.log" +logToFile(path, useOldStd=False) + + +def no_crash_excepthook(exc_type, exc_value, tb): + """This consumes the exception so Qt doesn't exit on un-handled exceptions.""" + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, tb) + return + + print("--- Unhanded Exception Start ---") + print(traceback.print_exception(exc_type, exc_value, tb)) + print("--- Unhanded Exception End ---") + + +sys.excepthook = no_crash_excepthook diff --git a/preditor/gui/qtdesigner/console_base_plugin.py b/preditor/gui/qtdesigner/console_base_plugin.py new file mode 100644 index 00000000..75faae50 --- /dev/null +++ b/preditor/gui/qtdesigner/console_base_plugin.py @@ -0,0 +1,48 @@ +from preditor.gui import qtdesigner + + +class OutputConsolePlugin(qtdesigner.QPyDesignerCustomWidgetPlugin): + def __init__(self, parent=None): + super(OutputConsolePlugin, self).__init__() + + self.initialized = False + + def initialize(self, core): + if self.initialized: + return + + self.initialized = True + + def isInitialized(self): + return self.initialized + + def createWidget(self, parent): + from preditor.gui.output_console import OutputConsole + + return OutputConsole(parent=parent, controller=None) + + def name(self): + return "OutputConsole" + + def group(self): + return "PrEditor Widgets" + + def icon(self): + from Qt.QtGui import QIcon + + return QIcon("") + + def toolTip(self): + return "" + + def whatsThis(self): + return "" + + def isContainer(self): + return False + + def includeFile(self): + return "preditor.gui.output_console" + + def domXml(self): + return '' diff --git a/preditor/gui/qtdesigner/console_predit_plugin.py b/preditor/gui/qtdesigner/console_predit_plugin.py new file mode 100644 index 00000000..6a19c0ee --- /dev/null +++ b/preditor/gui/qtdesigner/console_predit_plugin.py @@ -0,0 +1,48 @@ +from preditor.gui import qtdesigner + + +class ConsolePrEditPlugin(qtdesigner.QPyDesignerCustomWidgetPlugin): + def __init__(self, parent=None): + super(ConsolePrEditPlugin, self).__init__() + + self.initialized = False + + def initialize(self, core): + if self.initialized: + return + + self.initialized = True + + def isInitialized(self): + return self.initialized + + def createWidget(self, parent): + from preditor.gui.console import ConsolePrEdit + + return ConsolePrEdit(parent=parent, controller=None) + + def name(self): + return "ConsolePrEdit" + + def group(self): + return "PrEditor Widgets" + + def icon(self): + from Qt.QtGui import QIcon + + return QIcon("") + + def toolTip(self): + return "" + + def whatsThis(self): + return "" + + def isContainer(self): + return False + + def includeFile(self): + return "preditor.gui.console" + + def domXml(self): + return '' diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index e714b1bb..b6c79b38 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -6,8 +6,8 @@ 0 0 - 899 - 766 + 958 + 808 @@ -20,8 +20,11 @@ PrEditor - - + + + + + Qt::Vertical @@ -65,8 +68,8 @@ 0 0 - 156 - 29 + 938 + 447 @@ -146,9 +149,6 @@ - - - true @@ -157,38 +157,44 @@ 0 0 - 879 - 420 + 938 + 447 - - - + + + - + 0 0 - - + + Preferences - + + + 3 + + + 3 + + + 3 + + + 3 + - + 0 0 - - - - - Preferences - - + 3 @@ -199,26 +205,117 @@ 3 - 30 + 3 - 30 + 3 - - - - - + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + - - - + + + 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 + + + + + + + + + + Files + + + + 0 + 3 @@ -232,390 +329,412 @@ 3 - - - + + + Auto save on close - - - 3 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - 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 - - - - - - - - - - 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 - - - - - - - - - - Clear - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Clear console before running workbox code - - - - - - - - - - Qt::Vertical - - - - 20 - 0 - - - - - - - - + + + If a linked file has been deleted or modified, ask how to handle. + + + Prompt for linked file modification / deletion + + + true - - - 3 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Auto-Completion - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Auto-Complete in console - - - - - - - Auto-Complete in workbox - - - - - - - Highligh exact completion - - - - - - - - - - Code Highlighting - - - - 0 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Code highlighting in console - - - true - - - - - - - - - - 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 - 0 - - - - - + + + + + + + Clear + + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + - - - + + + Clear console before running workbox code - - - 3 - + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Auto-Completion + + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Auto-Complete in console + + + + + + + Auto-Complete in workbox + + + + + + + Highligh exact completion + + + + + + + + + + Code Highlighting + + + + 0 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Code highlighting in console + + + true + + + + + + + + + + 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 + 0 + + + + + + + + + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Numeric Settings + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Set the maximun number of backup files on disk per workbox. +Must be at least 1 + + + Max recently closed workboxes + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 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 + + + Max number of Backups + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 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 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + '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 @@ -628,354 +747,25 @@ 3 - - - - Numeric Settings + + 3 + + + + + This may take a long time. - - - 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.' - - - - - - - 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.' - - - - - - - - - - - - - Prefs files on disk + + Backup - - - 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 + + + + Browse - - - 20 - 0 - - - + @@ -984,11 +774,17 @@ Must be at least 1 - - - QDialogButtonBox::Close + + + Qt::Vertical - + + + 20 + 0 + + + @@ -997,29 +793,36 @@ Must be at least 1 - - - Qt::Horizontal - - - - 57 - 20 - + + + QDialogButtonBox::Close - + - + + + + Qt::Horizontal + + + + 0 + 20 + + + + + Qt::Vertical - 20 + 10 0 @@ -1034,9 +837,6 @@ Must be at least 1 - - - @@ -1044,8 +844,8 @@ Must be at least 1 0 0 - 899 - 22 + 958 + 21 @@ -1930,7 +1730,7 @@ This button removes those (very old) workboxes. GroupTabWidget QTabWidget -
preditor.gui.group_tab_widget.h
+
preditor.gui.group_tab_widget.group_tab_widget.h
1
diff --git a/preditor/gui/workbox_mixin.py b/preditor/gui/workbox_mixin.py index dea1024e..f12e512f 100644 --- a/preditor/gui/workbox_mixin.py +++ b/preditor/gui/workbox_mixin.py @@ -10,7 +10,7 @@ import time from pathlib import Path -import chardet +import charset_normalizer import Qt as Qt_py from Qt.QtCore import Qt, Signal from Qt.QtWidgets import QMessageBox, QStackedWidget @@ -256,17 +256,11 @@ def __exec_selected__(self, truncate=True): # the workbox. txt = '\n' * lineNum + txt - # execute the code + # execute the code and print the results to the console title = self.__workbox_trace_title__(selection=True) - ret, was_eval = self.__console__().executeString(txt, filename=title) - if was_eval: - # If the selected code was a statement print the result of the statement. - ret = repr(ret) - self.__console__().startOutputLine() - if truncate: - print(self.__truncate_middle__(ret, 100)) - else: - print(ret) + self.__console__().executeString( + txt, filename=title, echoResult=True, truncate=truncate + ) def __file_monitoring_enabled__(self): """Returns True if this workbox supports file monitoring. @@ -769,19 +763,6 @@ def __is_missing_linked_file__(self): missing = not Path(filename).is_file() return missing - def __truncate_middle__(self, s, n, sep=' ... '): - """Truncates the provided text to a fixed length, putting the sep in the middle. - https://www.xormedia.com/string-truncate-middle-with-ellipsis/ - """ - if len(s) <= n: - # string is already short-enough - return s - # half of the size, minus the seperator - n_2 = int(n) // 2 - len(sep) - # whatever's left - n_1 = n - n_2 - len(sep) - return '{0}{1}{2}'.format(s[:n_1], sep, s[-n_2:]) - @classmethod def __unix_end_lines__(cls, txt): """Replaces all windows and then mac line endings with unix line endings.""" @@ -835,7 +816,7 @@ def __save_prefs__( ) full_path = str(full_path) - self.__write_file__(full_path, self.__text__()) + self.__write_file__(full_path, self.__text__(), encoding=self._encoding) self._backup_file = get_relative_path(self.core_name, full_path) ret['backup_file'] = self._backup_file @@ -1032,8 +1013,15 @@ def __open_file__(cls, filename, strict=True): with open(filename, "rb") as f: text_bytes = f.read() - # Open file, detect source encoding and convert to utf-8 - encoding = chardet.detect(text_bytes)['encoding'] or 'utf-8' + try: + # If possible to decode as utf-8 use it as the encoding + text = text_bytes.decode("utf-8") + return "utf-8", text + except UnicodeDecodeError: + pass + + # Otherwise, attempt to detect source encoding and convert to utf-8 + encoding = charset_normalizer.detect(text_bytes)['encoding'] or 'utf-8' try: text = text_bytes.decode(encoding) except UnicodeDecodeError as e: diff --git a/preditor/scintilla/documenteditor.py b/preditor/scintilla/documenteditor.py index 09c0351d..9f512603 100644 --- a/preditor/scintilla/documenteditor.py +++ b/preditor/scintilla/documenteditor.py @@ -33,7 +33,7 @@ from .. import osystem, resourcePath from ..delayable_engine import DelayableEngine from ..enum import Enum, EnumGroup -from ..gui import QtPropertyInit +from ..utils.cute import QtPropertyInit from . import QsciScintilla, lang logger = logging.getLogger(__name__) diff --git a/preditor/stream/__init__.py b/preditor/stream/__init__.py index d4a9fa74..24658531 100644 --- a/preditor/stream/__init__.py +++ b/preditor/stream/__init__.py @@ -31,14 +31,10 @@ # buffer. manager.append_writes = False """ -from __future__ import absolute_import, print_function - import sys -STDERR = 1 -STDIN = 2 -STDOUT = 3 - +from ..constants import StreamType +from ..streamhandler_helper import StreamHandlerHelper # noqa: E402 from .director import Director # noqa: E402 from .manager import Manager # noqa: E402 @@ -47,15 +43,7 @@ """ active = None -__all__ = [ - "active", - "Director", - "install_to_std", - "Manager", - "STDERR", - "STDIN", - "STDOUT", -] +__all__ = ["active", "Director", "install_to_std", "Manager"] def install_to_std(out=True, err=True): @@ -73,8 +61,12 @@ def install_to_std(out=True, err=True): if active is None: active = Manager() if out: - sys.stdout = Director(active, STDOUT) + sys.stdout = Director(active, StreamType.STDOUT) + # Update any StreamHandler's that were setup using the old stdout + StreamHandlerHelper.replace_stream(sys.stdout.old_stream, sys.stdout) if err: - sys.stderr = Director(active, STDERR) + sys.stderr = Director(active, StreamType.STDERR) + # Update any StreamHandler's that were setup using the old stderr + StreamHandlerHelper.replace_stream(sys.stderr.old_stream, sys.stderr) return active diff --git a/preditor/stream/console_handler.py b/preditor/stream/console_handler.py new file mode 100644 index 00000000..24a363ca --- /dev/null +++ b/preditor/stream/console_handler.py @@ -0,0 +1,159 @@ +from __future__ import absolute_import + +import logging +import re +from dataclasses import dataclass +from typing import Optional + +from .. import plugins +from ..constants import StreamType +from . import Manager + +logger = logging.getLogger(__name__) + + +class DefaultDescriptor: + def __init__(self, *, ident=None, default=None): + self._default = default + self._ident = ident + + def __set_name__(self, owner, name): + self._name = "_" + name + + def __get__(self, obj, type): + if obj is None: + return self._default + + return getattr(obj, self._name, self._default) + + +class LoggingLevelDescriptor(DefaultDescriptor): + """Converts a string into a logging level or None. + + When set, the string will be converted to an int if possible otherwise the + provided value is stored. + """ + + _level_conversion = logging.Handler() + + def __set__(self, obj, value): + if value is not None: + try: + value = int(value) + except ValueError: + # convert logging level strings to their int values if possible. + try: + self._level_conversion.setLevel(value) + value = self._level_conversion.level + except Exception: + logger.warning(f"Unable to convert {value} to an int logging level") + value = 0 + setattr(obj, self._name, value) + + +class FormatterDescriptor(DefaultDescriptor): + """Stores a logging.Formatter object. + + If a string is passed it will be cast into a Formatter instance. + """ + + def __set__(self, obj, value): + if isinstance(value, str): + value = logging.Formatter(value) + setattr(obj, self._name, value) + + +@dataclass +class HandlerInfo: + _default_format = ( + '%(levelname)s %(module)s.%(funcName)s line:%(lineno)d - %(message)s' + ) + name: str + level: LoggingLevelDescriptor = LoggingLevelDescriptor() + plugin: Optional[str] = "Console" + formatter: FormatterDescriptor = FormatterDescriptor(default=_default_format) + + _attr_names = {"plug": "plugin", "fmt": "formatter", "lvl": "level"} + + def __post_init__(self): + # Process self.name as a string if level is not defined + if self.level is None: + # Clear self.name so you can define omit name to define a root logger + # For example passing "level=INFO" would set the root logger to info. + name = self.name + self.name = "" + + parts = self.__to_parts__(name) + for i, value in enumerate(parts): + key, _value = self.__parse_setting__(value, i) + setattr(self, key, _value) + + @classmethod + def __to_parts__(cls, value): + """Returns a list of args and kwargs. Kwargs are 2 item tuples.""" + sp = re.split(r'(\w+(? bool: + """Remove callback from manager and return if it was removed.""" + if callback not in self.callbacks: + return False self.callbacks.remove(callback) + return True + + def replay(self, callback): + """Replay the existing writes for the given callback. + + This iterates over all the stored writes and pass them to callback. This + is useful for when you are initializing a gui and want to include all + previous prints. + """ + for msg, state in self: + callback(msg, state) def get_value(self, fmt="[{state}:{msg}]"): return ''.join([fmt.format(msg=d[0], state=d[1]) for d in self]) @@ -65,10 +86,15 @@ def write(self, msg, state): msg (str): The text to be written. state: A identifier for how the text is to be written. For example if this write is coming from sys.stderr this will likely be set to - ``preditor.stream.STDERR``. + ``preditor.constants.StreamType.STDERR``. """ if self.store_writes: self.append((msg, state)) for callback in self.callbacks: - callback(msg, state) + try: + callback(msg, state) + except Exception: + print(" PrEditor Console failed ".center(79, "-"), file=sys.__stderr__) + traceback.print_exc(file=sys.__stderr__) + print(" PrEditor Console failed ".center(79, "-"), file=sys.__stderr__) diff --git a/preditor/utils/__init__.py b/preditor/utils/__init__.py index 545dcfc5..acef57d9 100644 --- a/preditor/utils/__init__.py +++ b/preditor/utils/__init__.py @@ -97,3 +97,37 @@ def loads_json(cls, json_str, source): ValueError: The error raised due to invalid json. """ return cls._load_json(source, json.loads, json_str) + + +class Truncate: + def __init__(self, text, sep='...'): + self.text = text + self.sep = sep + self.sep_spaces = f' {sep} ' + + def middle(self, n=100): + """Truncates the provided text to a fixed length, putting the sep in the middle. + https://www.xormedia.com/string-truncate-middle-with-ellipsis/ + """ + if len(self.text) <= n: + # string is already short-enough + return self.text + # half of the size, minus the seperator + n_2 = int(n) // 2 - len(self.sep_spaces) + # whatever's left + n_1 = n - n_2 - len(self.sep_spaces) + return '{0}{1}{2}'.format(self.text[:n_1], self.sep_spaces, self.text[-n_2:]) + + def lines(self, max_lines=20): + """Truncates the provided text to a maximum number of lines with a separator + at the end if required. + """ + lines = self.text.split("\n") + orig_len = len(lines) + lines = lines[:max_lines] + trim_len = len(lines) + if orig_len != trim_len: + lines.append("...") + truncated = "\n".join(lines) + + return truncated diff --git a/preditor/utils/call_stack.py b/preditor/utils/call_stack.py new file mode 100644 index 00000000..e712f671 --- /dev/null +++ b/preditor/utils/call_stack.py @@ -0,0 +1,86 @@ +import functools +import inspect +import logging +import threading + +_logger = logging.getLogger(__name__) + + +class CallStack: + """Decorator that logs the inputs and return of the decorated function. + + For most cases use `@preditor.utils.call_stack.log_calls`, but if you want + to create a custom configured version you can create your own instance of + `CallStack` to customize the output. + + Parameters: + logger: A python logging instance to write log messages to. + level: Write to logger using this debug level. + print: If set to True use the print function instead of python logging. + input_prefix: Text shown before the function name. + return_prefix: Text shown before the return data. + """ + + def __init__(self, logger=None, level=logging.DEBUG, print=True): + self._call_depth = threading.local() + self.logger = _logger if logger is None else logger + self.level = level + self.print = print + self.input_prefix = "\u2192" + self.return_prefix = "\u2190" + + @property + def indent(self): + return getattr(self._call_depth, "indent", 0) + + @indent.setter + def indent(self, indent): + self._call_depth.indent = indent + + def log(self, msg): + if self.print: + print(msg) + else: + self.logger.log(self.level, msg, stacklevel=2) + + def log_calls(self, func): + """Decorator that writes function input and return value. + If another decorated function call is made during the first call it's + output will be indented to reflect that. + """ + # Check for and remove self and cls arguments once during decoration + sig = inspect.signature(func) + params = list(sig.parameters.values()) + slice_index = 1 if params and params[0].name in ("self", "cls") else 0 + + @functools.wraps(func) + def wrapper(*args, **kwargs): + # remove 'self' or 'cls' from positional display (but do NOT remove kwargs) + # display_args = args[1:] if slice_index and args else args + display_args = args[slice_index:] + + # Generate repr of the calling arguments + parts = [repr(a) for a in display_args] + parts += [f"{k}={v!r}" for k, v in kwargs.items()] + arg_str = ", ".join(parts) + + indent = " " * self.indent + self.log(f"{indent}{self.input_prefix} {func.__qualname__}({arg_str})") + + self.indent += 1 + try: + result = func(*args, **kwargs) + finally: + self.indent -= 1 + + self.log(f"{indent}{self.return_prefix} {result!r}") + return result + + return wrapper + + +call_stack = CallStack() +"""An shared instance for ease of use and configuration""" + +log_calls = call_stack.log_calls +"""Use `from preditor.utils.call_stack import log_calls` as a shared decorator.""" diff --git a/preditor/utils/cute.py b/preditor/utils/cute.py index 0b893514..4de972df 100644 --- a/preditor/utils/cute.py +++ b/preditor/utils/cute.py @@ -1,7 +1,9 @@ -from __future__ import absolute_import - __all__ = ["ensureWindowIsVisible"] -from Qt.QtWidgets import QApplication +from functools import partial + +# NOTE: Only import QtWidgets and QtGui inside functions not at the module level +# to preserve headless environment support. +from Qt.QtCore import Property def ensureWindowIsVisible(widget): @@ -10,6 +12,8 @@ def ensureWindowIsVisible(widget): not intersect, it will reposition it to the top left corner of the highest numbered screen. Returns a boolean indicating if it had to move the widget. """ + from Qt.QtWidgets import QApplication + screens = QApplication.screens() geo = widget.geometry() @@ -29,3 +33,74 @@ def ensureWindowIsVisible(widget): widget.checkScreenGeo = True return True return False + + +def QtPropertyInit(name, default, callback=None, typ=None): + """Initializes a default Property value with a usable getter and setter. + + You can optionally pass a function that will get called any time the property + is set. If using the same callback for multiple properties, you may want to + use the preditor.decorators.singleShot decorator to prevent your function getting + called multiple times at once. This callback must accept the attribute name and + value being set. + + Example: + class TestClass(QWidget): + def __init__(self, *args, **kwargs): + super(TestClass, self).__init__(*args, **kwargs) + + stdoutColor = QtPropertyInit('_stdoutColor', QColor(0, 0, 255)) + pyForegroundColor = QtPropertyInit('_pyForegroundColor', QColor(0, 0, 255)) + + Args: + name(str): The name of internal attribute to store to and lookup from. + default: The property's default value. This will also define the Property type + if typ is not set. To define a property containing a list, dict or set, + pass the list, dict, or set class not an instance of the class. Ie pass + `list` not `[]`. See flake8-bugbear rule B006 for more info. + callback(callable): If provided this function is called when the property is + set. + typ (class, optional): If not None this value is used to specify the type of + the Property. This is useful when you need to specify a property as python's + object but pass a default value of a given class. + + Returns: + Property + """ + + # Prevent all instances of class sharing a mutable data structure. If the + # default is one of these classes and not a instance of them, replace them + # with an instance of the default class. + # See flake8-bugbear B006: Do not use mutable data structures for argument + # defaults. They are created during function definition time. All calls to + # the function reuse this one instance of that data structure, persisting + # changes between them. + is_mutable = default in (list, dict, set) + + def _getattrDefault(default, is_mutable, self, attrName): + try: + value = getattr(self, attrName) + except AttributeError: + # Create a unique instance of the default mutable class for self. + if is_mutable: + default = default() + + setattr(self, attrName, default) + return default + return value + + def _setattrCallback(callback, attrName, self, value): + setattr(self, attrName, value) + if callback: + callback(self, attrName, value) + + ga = partial(_getattrDefault, default, is_mutable) + sa = partial(_setattrCallback, callback, name) + # Use the default value's class if typ is not provided. + if typ is None: + if is_mutable: + # In this case the type is the same as the default + typ = default + else: + typ = default.__class__ + return Property(typ, fget=(lambda s: ga(s, name)), fset=(lambda s, v: sa(s, v))) diff --git a/pyproject.toml b/pyproject.toml index 77653e75..7f127a0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ QScintilla = "preditor.gui.workboxwidget:WorkboxWidget" [project.entry-points."preditor.plug.logging_handlers"] PrEditor = "preditor.gui.logger_window_handler:LoggerWindowHandler" +Console = "preditor.stream.console_handler:ConsoleHandler" [tool.setuptools] platforms = ["any"] diff --git a/requirements.txt b/requirements.txt index d6085dd3..c16958c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -chardet +charset-normalizer configparser>=4.0.2 future>=0.18.2 importlib-metadata>=4.8.3 diff --git a/tests/encodings/a_utf-8.txt b/tests/encodings/a_utf-8.txt new file mode 100644 index 00000000..f4daa4cd --- /dev/null +++ b/tests/encodings/a_utf-8.txt @@ -0,0 +1 @@ +→ diff --git a/tests/encodings/b_utf-8.txt b/tests/encodings/b_utf-8.txt new file mode 100644 index 00000000..15a7568c --- /dev/null +++ b/tests/encodings/b_utf-8.txt @@ -0,0 +1 @@ +😼→ diff --git a/tests/encodings/test_ecoding.py b/tests/encodings/test_ecoding.py new file mode 100644 index 00000000..841f7ce5 --- /dev/null +++ b/tests/encodings/test_ecoding.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import pytest + +from preditor.gui.workbox_mixin import WorkboxMixin + +this_dir = Path(__file__).parent + + +@pytest.mark.parametrize( + "filename, check_encoding", + ( + # This file doesn't have any strictly unicode characters in it so it would + # get detected as cp037 which doesn't get handled correctly on save. + ("a_utf-8.txt", "utf-8"), + # This file has a unicode only character as well as the previous text + ("b_utf-8.txt", "utf-8"), + ), +) +def test_workbox_mixin_open_file(filename, check_encoding): + """Test how preditor handles text encoding of varous files. + + To test a specific case, add a new .txt file next to this file and add it to + the parametrize decorator specifying the encoding that should be detected + and decoded. Use a comment to explain what this file is testing. + """ + filename = this_dir / filename + encoding, text = WorkboxMixin.__open_file__(filename) + check_bytes = filename.open("rb").read() + check_text = check_bytes.decode(check_encoding) + + assert encoding == check_encoding + assert text == check_text diff --git a/tests/test_cute.py b/tests/test_cute.py new file mode 100644 index 00000000..291b9385 --- /dev/null +++ b/tests/test_cute.py @@ -0,0 +1,97 @@ +from Qt.QtCore import QObject + +from preditor.utils.cute import QtPropertyInit + + +def test_mutable(): + class PropertyInit(QObject): + # It is preferred that you pass the class type for default if possible + a_list = QtPropertyInit("_a_list", list) + a_dict = QtPropertyInit("_a_dict", dict) + a_set = QtPropertyInit("_a_set", set) + # Test passing a mutable object as the default. In most cases doing this + # should be avoided as it leads to unexpected behavior. + shared_list = QtPropertyInit("_shared_list", []) + shared_dict = QtPropertyInit("_shared_dict", {}) + shared_set = QtPropertyInit("_shared_set", set()) + + def __init__(self, name): + super().__init__() + self.name = name + + def __repr__(self): + return self.name + + # Verify that the type was set correctly for all of these + assert PropertyInit.a_list.type == list + assert PropertyInit.a_dict.type == dict + assert PropertyInit.a_set.type == set + + a = PropertyInit("A") + b = PropertyInit("B") + + # Check that the storage variables haven't been created yet + assert not hasattr(a, "_a_list") + assert not hasattr(a, "_a_dict") + assert not hasattr(a, "_a_set") + assert not hasattr(a, "_shared_list") + assert not hasattr(a, "_shared_dict") + assert not hasattr(a, "_shared_set") + assert not hasattr(b, "_a_list") + assert not hasattr(b, "_a_dict") + assert not hasattr(b, "_a_set") + assert not hasattr(b, "_shared_list") + assert not hasattr(b, "_shared_dict") + assert not hasattr(b, "_shared_set") + + # Create the storage variables with the default value and verify + for x in (a, b): + x.a_list + x.a_dict + x.a_set + x.shared_list + x.shared_dict + x.shared_set + assert a._a_list == [] + assert a._a_dict == {} + assert a._a_set == set() + assert a._shared_list == [] + assert a._shared_dict == {} + assert a._shared_set == set() + assert b._a_list == [] + assert b._a_dict == {} + assert b._a_set == set() + assert b._shared_list == [] + assert b._shared_dict == {} + assert b._shared_set == set() + + # Check that the getters return the default value + assert a.a_list is a._a_list + assert a.a_dict is a._a_dict + assert a.a_set is a._a_set + assert a.shared_list is a._shared_list + assert a.shared_dict is a._shared_dict + assert a.shared_set is a._shared_set + assert b.a_list is b._a_list + assert b.a_dict is b._a_dict + assert b.a_set is b._a_set + assert b.shared_list is b._shared_list + assert b.shared_dict is b._shared_dict + assert b.shared_set is b._shared_set + + # Passing a list as the default prevents all instances from sharing the mutable + assert a.a_list is not b.a_list + assert a.a_dict is not b.a_dict + assert a.a_set is not b.a_set + # Passing a mutable object as the default causes all instances to share the mutable + assert a.shared_list is b.shared_list + assert a.shared_dict is b.shared_dict + assert a.shared_set is b.shared_set + + # Verify that the shared items are shared. + a.shared_list.append("a") + assert b.shared_list == ["a"] + a.shared_dict["b"] = "c" + assert b.shared_dict == {"b": "c"} + a.shared_set.add("d") + assert b.shared_set == set("d") diff --git a/tests/test_stream.py b/tests/test_stream.py index 9f16695f..a6d3507b 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,11 +1,13 @@ from __future__ import absolute_import import io +import logging import sys import pytest -from preditor.stream import STDERR, STDOUT, Director, Manager, install_to_std +from preditor.constants import StreamType +from preditor.stream import Director, Manager, install_to_std @pytest.fixture @@ -62,8 +64,8 @@ def __getattribute__(self, name): # Make directors that wrap sys.stdout and sys.stderr # This way we can check that the default behavior works - stdout_director = Director(manager, STDOUT) - stderr_director = Director(manager, STDERR) + stdout_director = Director(manager, StreamType.STDOUT) + stderr_director = Director(manager, StreamType.STDERR) # Wrap the stdout/stderr directors so we can check that # director.std_stream_wrapped is not set for these cases @@ -79,8 +81,8 @@ def __getattribute__(self, name): try: # Build a director here that will grab the nul streams # so we can check that they don't store them in .old_stream - nullout_director = Director(manager, STDOUT) - nullerr_director = Director(manager, STDERR) + nullout_director = Director(manager, StreamType.STDOUT) + nullerr_director = Director(manager, StreamType.STDERR) finally: # And make sure to restore the backed up stdout sys.stdout = orig_stdout @@ -164,25 +166,34 @@ def test_add_callback(manager, stdout, stderr): stdout.write(u'some text') bound = Bound() + def remove_callback(): + manager.remove_callback(bound.write) + assert bound.write not in manager.callbacks + # Base check that default kwargs work as expected assert manager.store_writes is True # disable_writes assert len(manager) == 1 # clear assert len(bound.data) == 0 # replay + assert bound.write not in manager.callbacks manager.add_callback(bound.write) + assert bound.write in manager.callbacks assert manager.store_writes is True # disable_writes assert len(manager) == 1 # Clear assert len(bound.data) == 0 # replay + remove_callback() manager.add_callback(bound.write, disable_writes=True) assert manager.store_writes is False # disable_writes assert len(manager) == 1 # Clear assert len(bound.data) == 0 # replay + remove_callback() manager.add_callback(bound.write, replay=True) assert manager.store_writes is False # disable_writes assert len(manager) == 1 # Clear assert len(bound.data) == 1 # replay + remove_callback() manager.add_callback(bound.write, clear=True) assert manager.store_writes is False # disable_writes assert len(manager) == 0 # Clear @@ -218,3 +229,49 @@ def test_install_to_std(): assert inst_out assert inst_err assert isinstance(manager, Manager) + + +@pytest.mark.parametrize( + "input,check", + ( + (["preditor.cli"], ["preditor.cli", None, "Console", None]), + (["preditor.cli,INFO"], ["preditor.cli", 20, "Console", None]), + (["preditor.prefs,30"], ["preditor.prefs", 30, "Console", None]), + (["preditor.cli,lvl=30"], ["preditor.cli", 30, "Console", None]), + (["preditor.cli,level=WARNING"], ["preditor.cli", 30, "Console", None]), + # Test escaping and complex formatter strings + (["preditor,formatter=%(msg)s"], ["preditor", None, "Console", "%(msg)s"]), + (["preditor,fmt=%(msg)s,POST"], ["preditor", None, "Console", "%(msg)s,POST"]), + ([r"preditor,fmt=M\=%(msg)s"], ["preditor", None, "Console", "M=%(msg)s"]), + ([r"preditor,fmt=M\\=%(msg)s"], ["preditor", None, "Console", r"M\=%(msg)s"]), + ( + [r"preditor,fmt=M\=%(msg)s,POST"], + ["preditor", None, "Console", "M=%(msg)s,POST"], + ), + ( + [r"plug=PrEditor,fmt=A\=%(msg)sG\=G,level=WARNING,name=six"], + ["six", 30, "PrEditor", "A=%(msg)sG=G"], + ), + # Order of kwargs doesn't matter and there are no trailing commas + ( + [r"plug=PrEditor,fmt=A\=%(msg)s,G\=G,level=WARNING,name=six"], + ["six", 30, "PrEditor", "A=%(msg)s,G=G"], + ), + ( + [r"plug=PrEditor,fmt=A\=%(msg)s,G\=G,B,level=WARNING,name=six"], + ["six", 30, "PrEditor", "A=%(msg)s,G=G,B"], + ), + ), +) +def test_handler_info(input, check): + from preditor.stream.console_handler import HandlerInfo + + hi = HandlerInfo(*input) + assert hi.name == check[0] + assert hi.level == check[1] + assert hi.plugin == check[2] + assert isinstance(hi.formatter, logging.Formatter) + if check[3] is None: + # Treat None as the default formatter + check[3] = HandlerInfo._default_format + assert hi.formatter._fmt == check[3]