htmlpyqt5qtextedit

Text remaining indented after indented block when using insertHtml in QTextEdit


I am making a GUI that has a window with a QTextEdit widget that I am using as a log to track the status of a process running on a separate thread. I have been using HTML to try to control the color and formatting of the text sent to the log but have been running into a number of issues, most of which I have found work-arounds, but I have yet to find information on this on SO or Google.

Every message that is sent to the log is timestamped and wrapped with the <p> HTML tag. Since I have a few error messages that print across multiple lines, I have been trying to indent the secondary lines. When I do this, however, every message that is printed after that is also indented, even if I specifically set a 0px indent with the HTML style tag.

I've put together a minimum reproducible example of what I currently have here:

import sys
from PyQt5.QtWidgets import (
    QApplication,
    QTextEdit,
    )


class MainWindow(QTextEdit):
    def __init__(self):
        super().__init__()

        self.setStyleSheet("background-color: #1E1F22;")
        self.setReadOnly(True)
        self.setLineWrapMode(QTextEdit.NoWrap)

        self.send_to_log()

    def send_to_log(self):
        # Emulate a standard message
        timestamp = f"23:59:59 | "
        text = f"<p style='color: #e5e5e5;'>{timestamp}sample_file1.csv</p>"
        self.insertHtml(text)
        self.append("")
        # ^ Only way I've found to get QTextEdit to print each message to a new line without having a massive amount of
        # whitespace between messages

        # Emulate an error message
        timestamp = f"23:59:59 | "
        text = f"<p style='color: #F75464;'>{timestamp}"
        text += f"<b>FittingError:</b> curve_fit failed while processing:</p>"
        text += f"<p style='color: #F75464; text-indent: 65px'>sample_file1.csv</p>"
        text += (f"<p style='color: #F75464; text-indent: 65px'><b>RuntimeError:</b> "
             f"Optimal parameters not found: Number of calls to function has reached maxfev = 800</p>")
        self.insertHtml(text)
        self.append("")

        # Emulate a standard message
        for i in [2, 3]:
            timestamp = f"23:59:59 | "
            text = f"<p style='color: #e5e5e5; text-indent: 0px'>{timestamp}sample_file{i}.csv</p>"
            self.insertHtml(text)
            self.append("")


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

This is what I get after running the code: MRE output

I was hoping that 23:59:59 | sample_file2 and subsequent lines would no longer be indented, resuming the normal formatting, but it seems as though the indent from the previous message is persisting somehow.

I would expect to be able to manually set the text-indent property back to 0, or 1px, but neither work.

Additional things I've tried:

I'm relatively new to HTML and PyQt, so I might be missing something obvious, but I have been looking for a solution for a couple days now without finding anything as of yet, so any help is appreciated.


Solution

  • Qt rich text management

    A few important aspects to consider about Qt text widgets that provide functions such as setHtml(), toHtml() and insertHtml() (including QLabel's setText() that guesses if the given string may use HTML tags):

    Finally, QTextDocument uses the QTextCursor interface for editing, always inserting new content with the current text format, which is the format of the character at the left of the current cursor position (or the right if at the beginning of a document). When using insertHtml() it also assumes that the inserted html is just a continuation of the previous content, meaning that appending a HTML starting with a paragraph (<p>) will just merge it with the previous format ignoring the new paragraph.

    This may seem unintuitive, but the reason comes from the fact that insertHtml() is actually used when doing clipboard operations: when copying rich text, the clipboard actually contains a fully compliant HTML document (even if you just copied one letter) and when pasting that content Qt has no way to know if the insertion is to be considered as coming from the clipboard or not.
    Interestingly enough, that's exactly what most rich text editors in web browsers do: even when you select and copy a full paragraph with custom "block" aspects (such as indentation), when it's pasted in an existing paragraph, those aspects will be ignored, and the current one will be preserved instead.

    Possible solutions

    Now, there are many ways to achieve what you want.

    The simplest solution is to always add new HTML starting with a line break (<br>). With this, you could even just use <span>, and that's because text-indent (following the HTML standard) actually applies the indentation only to the first line of the paragraph.

    Another alternative is to always insert a new block with a default QTextBlockFormat before adding a new paragraph. To do so, you need to access the QTextCursor of the document, then you can use its own insertHtml() directly (which is what QTextEdit and therefore QTextDocument actually do):

        tc = self.textCursor()
        ...
        tc.insertBlock(QTextBlockFormat())
        tc.insertHtml(text)
        ...
    

    In case you want to avoid the insertion of an empty line at the beginning while the contents are still empty, just check the current cursor position:

        tc.movePosition(tc.End) # 1
        if (
            tc.position() # 2
            and not tc.atBlockStart() # a
            or tc.blockFormat().textIndent() # b
        ):
            tc.insertBlock(QTextBlockFormat()) # 3
        tc.insertHtml(text)
        ...
    

    What the above does is:

    1. move cursor to the end of the document (necessary in case the user has clicked on the editor, possibly to select some text);

    2. check if some content already exists; and if it does:

      a. ensure that it's not at the beginning of a block/paragraph;

      b. alternatively, verify if the current block format has indentation set by the text-indent CSS;

    3. eventually insert a new block/paragraph;

    Final considerations

    While the HTML features Qt provides may be sometimes helpful and simpler, they often presents some important drawbacks similar to those you're facing.

    One possibility is to properly set the defaultStyleSheet() on the document and also use HTML classes, but some proper digging into the documentation could actually simplify all the above (and would certainly be helpful in your experience).

    Consider the following:

    class MainWindow(QTextEdit):
        def __init__(self):
            ...
            doc = self.document()
            doc.setIndentWidth(65)
    
            self.standard_format = QTextBlockFormat()
            self.standard_format.setForeground(QColor('#e5e5e5'))
            self.error_format = QTextBlockFormat()
            self.error_format.setForeground(QColor('#f75464'))
            self.error_format.setIndent(1)
            self.error_format.setTextIndent(-65)
            self.send_to_log()
    
        def add_content(self, content, format):
            tc = self.textCursor()
            tc.movePosition(tc.End)
            if (
                tc.position()
                and not tc.atBlockStart()
                or tc.blockFormat().textIndent()
            ):
                tc.insertBlock(format)
    
            pos = tc.position()
            tc.insertHtml(content)
            tc.setPosition(pos, tc.KeepAnchor)
            if tc.blockFormat() != format:
                tc.setBlockFormat(format)
            tc.setCharFormat(format.toCharFormat())
    
        def add_standard_message(self, timestamp, message):
            self.add_content(
                '{} | {}'.format(timestamp, message), 
                self.standard_format
            )
    
        def add_error_message(self, timestamp, err_contents):
            self.add_content(
                '{} | {}'.format(timestamp, '<br>'.join(err_contents)), 
                self.error_format
            )
    
        def send_to_log(self):
            timestamp = "23:59:59"
            self.add_standard_message(timestamp, 'sample_file1.csv')
            err_contents = (
                '<b>FittingError:</b> curve_fit failed while processing', 
                'sample_file1.csv', 
                '<b>RuntimeError</b> Optimal parameters not found: Number of '
                    'calls to function has reached maxfev = 800'
            )
            self.add_error_message(timestamp, err_contents)
    
            for i in (2, 3):
                self.add_standard_message(
                    timestamp, 'sample_file{}.csv'.format(i))
    

    Finally, and completely unrelated but still quite important, you shall never set generic QSS (Qt Style Sheet) properties on complex widgets as you did with self.setStyleSheet("background-color: #1E1F22;"), as they will propagate to all child widgets, including complex widgets that require full definition of their properties whenever any property is set (like scroll bars). If you want to set the background of the QTextEdit, the only valid and acceptable way to do so is by using proper selectors; for instance, in your case:

        self.setStyleSheet("MainWindow { background-color: #1E1F22; }")
    

    The above assumes that, based on your code, MainWindow is the target of the QSS, and using a QTextEdit selector would also work, since MainWindow inherits from it; obviously, if MainWindow contains the QTextEdit, you may need a different selector that actually targets that widget or its class(es).