I build a custom PlainTextEdit to serve as a logviewer widget. I have a external lineEdit widget to specify a search pattern (similar to how you use ctrl+f to find a text on a website). The lineEdits textChanged signal is connected to the _find_text() method. The textEdit should highlight the respective matches. However, there seems to be some issue with the _clear_search_data() method, as selections made with the self._highlight_cursor during previous matching continue to remain highlighted.
class LogTextEdit(QtWidgets.QPlainTextEdit):
"""
"""
mousePressedSignal = QtCore.Signal(object)
matchCounterChanged = QtCore.Signal(tuple)
def __init__(self, parent):
"""
"""
super(LogTextEdit, self).__init__(parent)
# some settings
self.setReadOnly(True)
self.setMaximumBlockCount(20000)
self.master_document = QtGui.QTextDocument() # always log against master
self.master_doclay = QtWidgets.QPlainTextDocumentLayout(self.master_document)
self.master_document.setDocumentLayout(self.master_doclay)
self.proxy_document = QtGui.QTextDocument() # display the filtered document
self.proxy_doclay = QtWidgets.QPlainTextDocumentLayout(self.proxy_document)
self.proxy_document.setDocumentLayout(self.proxy_doclay)
self.setDocument(self.proxy_document)
# members
self._matches = []
self._current_match = 0
self._current_search = ("", 0, False)
self._content_timestamp = 0
self._search_timestamp = 0
self._first_visible_index = 0
self._last_visible_index = 0
self._matches_to_highlight = set()
self._matches_label = QtWidgets.QLabel()
self._cursor = self.textCursor()
pos = QtCore.QPoint(0, 0)
self._highlight_cursor = self.cursorForPosition(pos)
# text formatting related
self._format = QtGui.QTextCharFormat()
self._format.setBackground(QtCore.Qt.red)
self.font = self.document().defaultFont()
self.font.setFamily('Courier New') # fixed width font
self.document().setDefaultFont(self.font)
self.reset_text_format = QtGui.QTextCharFormat()
self.reset_text_format.setFont(self.document().defaultFont())
# right click context menu
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.copy_action = QtWidgets.QAction('Copy', self)
self.copy_action.setStatusTip('Copy Selection')
self.copy_action.setShortcut('Ctrl+C')
self.addAction(self.copy_action)
# Initialize state
self.updateLineNumberAreaWidth()
self.highlightCurrentLine()
# Signals
# linearea
self.blockCountChanged.connect(self.updateLineNumberAreaWidth)
self.updateRequest.connect(self.updateLineNumberArea)
self.cursorPositionChanged.connect(self.highlightCurrentLine)
# copy
self.customContextMenuRequested.connect(self.context_menu)
self.copy_action.triggered.connect(lambda: self.copy_selection(QtGui.QClipboard.Mode.Clipboard))
def appendMessage(self, msg:str, document: QtGui.QTextDocument):
cursor = QtGui.QTextCursor(document)
cursor.movePosition(QtGui.QTextCursor.MoveOperation.End, QtGui.QTextCursor.MoveMode.MoveAnchor)
cursor.beginEditBlock()
cursor.insertBlock()
cursor.insertText(msg)
cursor.endEditBlock()
self._content_timestamp = time.time()
def _move_to_next_match(self):
"""
Moves the cursor to the next occurrence of search pattern match,
scrolling up/down through the content to display the cursor position.
When the cursor is not set and this method is called, the cursor will
be moved to the first match. Subsequent calls move the cursor through
the next matches (moving forward). When the cursor is at the last match
and this method is called, the cursor will be moved back to the first
match. If there are no matches, this method does nothing.
"""
if not self._matches:
return
if self._current_match >= len(self._matches):
self._current_match = 0
self._move_to_match(self._matches[self._current_match][0],
self._matches[self._current_match][1])
self._current_match += 1
def _move_to_prev_match(self):
"""
Moves the cursor to the previous occurrence of search pattern match,
scrolling up/down through the content to display the cursor position.
When called the first time, it moves the cursor to the last match,
subsequent calls move the cursor backwards through the matches. When
the cursor is at the first match and this method is called, the cursor
will be moved back to the last match
If there are no matches, this method does nothing.
"""
if not self._matches:
return
if self._current_match < 0:
self._current_match = len(self._matches) - 1
self._move_to_match(self._matches[self._current_match][0],
self._matches[self._current_match][1])
self._current_match -= 1
def _move_to_match(self, pos, length):
"""
Moves the cursor in the content box to the given pos, then moves it
forwards by "length" steps, selecting the characters in between
@param pos: The starting position to move the cursor to
@type pos: int
@param length: The number of steps to move+select after the starting
index
@type length: int
@postcondition: The cursor is moved to pos, the characters between pos
and length are selected, and the content is scrolled
up/down to ensure the cursor is visible
"""
self._cursor.setPosition(pos)
self._cursor.movePosition(QtGui.QTextCursor.Right,
QtGui.QTextCursor.KeepAnchor,
length)
self.setTextCursor(self._cursor)
self.ensureCursorVisible()
#self._scrollbar_value = self._log_scrollbar.value()
self._highlight_matches()
self._matches_label.setText('%d:%d matches'
% (self._current_match + 1,
len(self._matches)))
def _find_text(self, pattern:str, flags:int, isRegexPattern:bool):
"""
Finds and stores the list of text fragments matching the search pattern
entered in the search box.
@postcondition: The text matching the search pattern is stored for
later access & processing
"""
prev_search = self._current_search
self._current_search = (pattern, flags, isRegexPattern)
search_has_changed = (
self._current_search[0] != prev_search[0] or
self._current_search[1] != prev_search[1] or
self._current_search[2] != prev_search[2]
)
if not self._current_search[0]: # Nothing to search for, clear search data
self._clear_search_data()
return
if self._content_timestamp <= self._search_timestamp and not search_has_changed:
self._move_to_next_match()
return
# New Search
self._clear_search_data()
try:
match_objects = re.finditer(str(pattern), self.toPlainText(), flags)
for match in match_objects:
index = match.start()
length = len(match.group(0))
self._matches.append((index, length))
if not self._matches:
self._matches_label.setStyleSheet('QLabel {color : gray}')
self._matches_label.setText('No Matches Found')
self._matches_to_highlight = set(self._matches)
self._update_visible_indices()
self._highlight_matches()
self._search_timestamp = time.time()
# Start navigating
self._current_match = 0
self._move_to_next_match()
except re.error as err:
self._matches_label.setText('ERROR: %s' % str(err))
self._matches_label.setStyleSheet('QLabel {color : indianred}')
def _highlight_matches(self):
"""
Highlights the matches closest to the current match
(current = the one the cursor is at)
(closest = up to 300 matches before + up to 300 matches after)
@postcondition: The matches closest to the current match have a new
background color (Red)
"""
if not self._matches_to_highlight or not self._matches:
return # nothing to match
# Update matches around the current one (300 before and 300 after)
highlight = self._matches[max(self._current_match - 300, 0):
min(self._current_match + 300, len(self._matches))]
matches = list(set(highlight).intersection(self._matches_to_highlight))
for match in matches:
self._highlight_cursor.setPosition(match[0])
self._highlight_cursor.movePosition(QtGui.QTextCursor.Right,
QtGui.QTextCursor.KeepAnchor,
match[-1])
self._highlight_cursor.setCharFormat(self._format)
self._matches_to_highlight.discard(match)
def _clear_search_data(self):
"""
Removes the text in the search pattern box, clears all highlights and
stored search data
@postcondition: The text in the search field is removed, match list is
cleared, and format/selection in the main content box
are also removed.
"""
self._matches = []
self._matches_to_highlight = set()
self._search_timestamp = 0
self._matches_label.setText('')
format = QtGui.QTextCharFormat()
self._highlight_cursor.setPosition(QtGui.QTextCursor.Start)
self._highlight_cursor.movePosition(QtGui.QTextCursor.End, mode=QtGui.QTextCursor.KeepAnchor)
self._highlight_cursor.setCharFormat(format)
self._highlight_cursor.clearSelection()
def _update_visible_indices(self):
"""
Updates the stored first & last visible text content indices so we
can focus operations like highlighting on text that is visible
@postcondition: The _first_visible_index & _last_visible_index are
up to date (in sync with the current viewport)
"""
viewport = self.viewport()
try:
top_left = QtCore.QPoint(0, 0)
bottom_right = QtCore.QPoint(viewport.width() - 1,
viewport.height() - 1)
first = self.cursorForPosition(top_left).position()
last = self.cursorForPosition(bottom_right).position()
self._first_visible_index = first
self._last_visible_index = last
except IndexError: # When there's nothing in the content box
pass
EDIT: Using a the QSyntaxHightlighter is a elegant way for just highlighting, however I must use the manuel method for two reasons a) log files can get pretty heavy, so the solution above allows us to only limit hightlighting around the visible line range b) I must be able to jump between matches in the document
