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)