Ejemplo n.º 1
0
class LogWindow(QFrame):
    """
    Simple log window based on QPlainTextEdit using ExtraSelection to
    highlight input/output sections with different backgrounds, see:
    http://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html
    """
    def __init__(self, *args):
        super().__init__(*args)
        self.setFont(monospace())
        self.textctrl = QPlainTextEdit()
        self.textctrl.setFont(monospace())  # not inherited on windows
        self.textctrl.setReadOnly(True)
        self.textctrl.setUndoRedoEnabled(False)
        self.infobar = RecordInfoBar(self.textctrl)
        self.linumbar = LineNumberBar(self.textctrl)
        self.setLayout(
            HBoxLayout([self.infobar, self.linumbar, self.textctrl],
                       tight=True))
        self.records = []
        self.formats = {}
        self._enabled = {}
        self._domains = set()
        self.loglevel = 'INFO'
        self._maxlen = 0
        self._rec_lines = deque()
        self.default_format = QTextCharFormat()

    @property
    def maxlen(self) -> int:
        """Maximum number of displayed log records. Default is ``0`` which
        means infinite."""
        return self._maxlen

    @maxlen.setter
    def maxlen(self, maxlen: int):
        maxlen = maxlen or 0
        if self._maxlen != maxlen:
            self._maxlen = maxlen
            self._rec_lines = deque(maxlen=maxlen)
            self.rebuild_log()

    def highlight(self, domain: str, color: QColor):
        """Configure log records with the given *domain* to be colorized in
        the given color."""
        format = QTextCharFormat()
        format.setProperty(QTextFormat.FullWidthSelection, True)
        format.setBackground(color)
        self.formats[domain] = format

    def setup_logging(self, level: str = 'INFO', fmt: str = '%(message)s'):
        """Redirect exceptions and :mod:`logging` to this widget."""
        level = (logging.getLevelName(level)
                 if isinstance(level, int) else level.upper())
        self.loglevel = level
        self.logging_enabled = True
        root = logging.getLogger('')
        formatter = logging.Formatter(fmt)
        handler = RecordHandler(self)
        handler.setFormatter(formatter)
        root.addHandler(handler)
        root.setLevel(level)
        sys.excepthook = self.excepthook

    def enable_logging(self, enable: bool):
        """Turn on/off display of :mod:`logging` log events."""
        self.logging_enabled = enable
        self.set_loglevel(self.loglevel)

    def set_loglevel(self, loglevel: str):
        """Set minimum log level of displayed log events."""
        self.loglevel = loglevel = loglevel.upper()
        index = LOGLEVELS.index(loglevel)
        if any([
                self._enable(level, i <= index and self.logging_enabled)
                for i, level in enumerate(LOGLEVELS)
        ]):
            self.rebuild_log()

    def enable(self, domain: str, enable: bool):
        """Turn on/off log records with the given domain."""
        if self._enable(domain, enable):
            self.rebuild_log()

    def _enable(self, domain: str, enable: bool) -> bool:
        """Internal method to turn on/off display of log records with the
        given domain.

        Returns whether calling :meth:`rebuild_log` is necessary."""
        if self.enabled(domain) != enable:
            self._enabled[domain] = enable
            return self.has_entries(domain)
        return False

    def enabled(self, domain: str) -> bool:
        """Return if the given domain is configured to be displayed."""
        return self._enabled.get(domain, True)

    def has_entries(self, domain: str) -> bool:
        """Return if any log records with the given domain have been
        emitted."""
        return domain in self._domains

    def append_from_binary_stream(self, domain, text, encoding='utf-8'):
        """Append a log record from a binary utf-8 text stream."""
        text = text.strip().decode(encoding, 'replace')
        if text:
            self.append(LogRecord(time.time(), domain, text))

    def excepthook(self, *args, **kwargs):
        """Exception handler that prints exceptions and appends a log record
        instead of exiting."""
        traceback.print_exception(*args, **kwargs)
        logging.error("".join(traceback.format_exception(*args, **kwargs)))

    def rebuild_log(self):
        """Clear and reinsert all configured log records into the text
        control.

        This is used internally if the configuration has changed such that
        previously invisible log entries become visible or vice versa."""
        self.textctrl.clear()
        self.infobar.clear()
        shown_records = [r for r in self.records if self.enabled(r.domain)]
        for record in shown_records[-self.maxlen:]:
            self._append_log(record)

    def append(self, record):
        """Add a :class:`LogRecord`. This can be called by users!"""
        self.records.append(record)
        self._domains.add(record.domain)
        if self.enabled(record.domain):
            self._append_log(record)

    def _append_log(self, record):
        """Internal method to insert a displayed record into the underlying
        :class:`QPlainTextEdit`."""
        self.infobar.add_record(record)
        self._rec_lines.append(record.text.count('\n') + 1)

        # NOTE: For some reason, we must use `setPosition` in order to
        # guarantee a absolute, fixed selection (at least on linux). It seems
        # almost if `movePosition(End)` will be re-evaluated at any time the
        # cursor/selection is used and therefore always point to the end of
        # the document.

        cursor = QTextCursor(self.textctrl.document())
        cursor.movePosition(QTextCursor.End)
        pos0 = cursor.position()
        cursor.insertText(record.text + '\n')
        pos1 = cursor.position()

        cursor = QTextCursor(self.textctrl.document())
        cursor.setPosition(pos0)
        cursor.setPosition(pos1, QTextCursor.KeepAnchor)

        selection = QTextEdit.ExtraSelection()
        selection.format = self.formats.get(record.domain, self.default_format)
        selection.cursor = cursor

        selections = self.textctrl.extraSelections()
        if selections:
            # Force the previous selection to end at the current block.
            # Without this, all previous selections are be updated to span
            # over the rest of the document, which dramatically impacts
            # performance because it means that all selections need to be
            # considered even if showing only the end of the document.
            selections[-1].cursor.setPosition(pos0, QTextCursor.KeepAnchor)
        selections.append(selection)
        self.textctrl.setExtraSelections(selections[-self.maxlen:])
        self.textctrl.ensureCursorVisible()

        if self.maxlen:
            # setMaximumBlockCount() must *not* be in effect while inserting
            # the text, because it will mess with the cursor positions and
            # make it nearly impossible to create a proper ExtraSelection!
            num_lines = sum(self._rec_lines)
            self.textctrl.setMaximumBlockCount(num_lines + 1)
            self.textctrl.setMaximumBlockCount(0)