I am working on a custom notepad style log viewer in Python 3.11 using PySide6.
The ui is created with QtDesigner and must be compiled into python with pyuic command and is imported into the following code with import "main_ui".
The application loads a file called sample.log into a QPlainTextEdit field. Then the user can click a button to find and highlight all lines containing "Error" substring.
The relative position of each highlighted line is indicated in a custom scrollbar with red lines.
My problem is how to properly calculate the positions for the red lines in the scrollbar? My current logic works, but the positions are not accurate.
This is what the interface looks like:
All necessary code is provided in this post, but additionally can be downloaded via this zip file logtool.zip which includes these files:
This is what the main program logtool.py looks like:
import sys
import os
import os.path
from PySide6 import QtWidgets
from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import QMainWindow, QScrollBar
from PySide6.QtGui import QTextCursor, QTextCharFormat, QColor, QPainter, QPen
import main_ui
substring_list = ["error", "exception", "fail"] # Strings to look for in log file that indicate an error
class MyScroll(QScrollBar):
def __init__(self, parent=None):
super(MyScroll, self).__init__(parent)
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
pen = QPen()
pen.setWidth(3)
pen.setColor(Qt.red)
pen.setStyle(Qt.SolidLine)
painter.setPen(pen)
try: # return if values is not set
self.values
except:
return
for value in self.values:
painter.drawLine(0, value, 15, value)
def draw(self, values):
self.values = values
self.update()
class MainWindow(QMainWindow, main_ui.Ui_main):
def __init__(self, parent=None):
super(MainWindow, self).__init__(parent)
self.setupUi(self) # setup the GUI --> function generated by pyuic4
self.txtEdit.setCenterOnScroll(True)
fullpath = "sample.log" # aos_diag log file
self.readfile(fullpath)
self.lines = []
self.scroll = MyScroll()
self.txtEdit.setVerticalScrollBar(self.scroll)
self.show()
def calc_drawline_pixel(self, lineno):
widget = self.txtEdit.verticalScrollBar()
total_lines = widget.maximum()
scroll_pixel_height = widget.height()
factor = lineno / total_lines
uparrow_height = 15
draw_at_pixel = int(factor * scroll_pixel_height) + uparrow_height
return draw_at_pixel
@Slot()
def on_btnMarkErrors_clicked(self):
self.lines = [] # lines is a list of line numbers which match search
self.pos = 0
string = self.txtEdit.toPlainText()
reclist = string.split("\n")
for lineno, line in enumerate(reclist):
flag = any(substring.lower() in line.lower() for substring in substring_list) # if any substring in list is in the line then return true
if flag:
self.markline(lineno)
self.lines.append(lineno)
self.lines = sorted(self.lines)
# fill values list and pass to draw method of scrollbar
values = []
for lineno in self.lines:
pixel = self.calc_drawline_pixel(lineno)
values.append(pixel)
self.scroll.draw(values)
def markline(self, line_number):
"""marks line with red highlighter"""
widget = self.txtEdit
cursor = QTextCursor(widget.document().findBlockByLineNumber(line_number)) # position cursor on the given line
cursor.select(QTextCursor.LineUnderCursor) # Select the line.
fmt = QTextCharFormat()
RED = QColor(228,191,190)
fmt.setBackground(RED)
cursor.mergeCharFormat(fmt) # Apply the format to the selected text
def readfile(self, fullpath):
fullpath = fullpath.replace("\\", "/") # flip all backslashes to forward slashes because python prefers
path,file = os.path.split(fullpath) # extract path and file from fullpath
fin = open(fullpath, encoding="utf8", errors='ignore')
content = fin.read()
fin.close()
self.txtEdit.setPlainText(content)
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
app.setStyle("Fusion")
myapp = MainWindow()
rc = app.exec()
sys.exit(rc)
This is what main_ui.py looks like:
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QMainWindow,
QPlainTextEdit, QPushButton, QSizePolicy, QSpacerItem,
QStatusBar, QVBoxLayout, QWidget)
class Ui_main(object):
def setupUi(self, main):
if not main.objectName():
main.setObjectName(u"main")
main.resize(602, 463)
self.centralwidget = QWidget(main)
self.centralwidget.setObjectName(u"centralwidget")
self.verticalLayout = QVBoxLayout(self.centralwidget)
self.verticalLayout.setObjectName(u"verticalLayout")
self.frame = QFrame(self.centralwidget)
self.frame.setObjectName(u"frame")
self.frame.setFrameShape(QFrame.NoFrame)
self.frame.setFrameShadow(QFrame.Raised)
self.horizontalLayout = QHBoxLayout(self.frame)
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.horizontalLayout.setContentsMargins(0, 0, 0, 0)
self.btnMarkErrors = QPushButton(self.frame)
self.btnMarkErrors.setObjectName(u"btnMarkErrors")
self.horizontalLayout.addWidget(self.btnMarkErrors)
self.horizontalSpacer_2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout.addItem(self.horizontalSpacer_2)
self.verticalLayout.addWidget(self.frame)
self.txtEdit = QPlainTextEdit(self.centralwidget)
self.txtEdit.setObjectName(u"txtEdit")
font = QFont()
font.setFamilies([u"Consolas"])
self.txtEdit.setFont(font)
self.txtEdit.setLineWrapMode(QPlainTextEdit.NoWrap)
self.verticalLayout.addWidget(self.txtEdit)
main.setCentralWidget(self.centralwidget)
self.statusbar = QStatusBar(main)
self.statusbar.setObjectName(u"statusbar")
main.setStatusBar(self.statusbar)
self.retranslateUi(main)
QMetaObject.connectSlotsByName(main)
# setupUi
def retranslateUi(self, main):
main.setWindowTitle(QCoreApplication.translate("main", u"Log Viewer", None))
self.btnMarkErrors.setText(QCoreApplication.translate("main", u"Mark Errors", None))
One option is to set the sliderPosition
of a QStyleOptionSlider to the line number where the line of text is centered in the viewport, and then use it to get the center position of the scroll bar slider with QStyle.subControlRect()
.
def calc_drawline_pixel(self, lineno):
value = lineno - self.scroll.pageStep() // 2
opt = QtWidgets.QStyleOptionSlider()
self.scroll.initStyleOption(opt)
opt.sliderPosition = value
draw_at_pixel = self.scroll.style().subControlRect(
QtWidgets.QStyle.CC_ScrollBar, opt, QtWidgets.QStyle.SC_ScrollBarSlider
).center().y()
if value < 0: # lineno is less than half the pageStep, sliderPosition was capped at the minimum (0)
opt.sliderPosition = -value
dy = self.scroll.style().subControlRect(
QtWidgets.QStyle.CC_ScrollBar, opt, QtWidgets.QStyle.SC_ScrollBarSlider
).center().y() - draw_at_pixel
draw_at_pixel -= dy
return draw_at_pixel