Search code examples
pythonpyside6pyqt6

Log Viewer wth Error Highlighting in Scrollbar


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:

enter image description here

All necessary code is provided in this post, but additionally can be downloaded via this zip file logtool.zip which includes these files:

  1. logtool.py
  2. main.ui
  3. main_ui.py (built from main.ui using Python6/Scripts/pyside6-uic.exe)
  4. sample.log

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))

Solution

  • 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