Better QPlainTextEdit With Line Numbers

My last post was an implementation of a Qt widget which displays text with line numbers. I found that it has a few limitations. The biggest was a performance penalty when dealing with large documents. I’ve since re-factored and rewritten the class to make the performance acceptable. I’ve also cleaned up the code a bit and added a highlight to the current line.


'''
Text widget with support for line numbers
'''

from PyQt4.Qt import QFrame
from PyQt4.Qt import QHBoxLayout
from PyQt4.Qt import QPainter
from PyQt4.Qt import QPlainTextEdit
from PyQt4.Qt import QRect
from PyQt4.Qt import QTextEdit
from PyQt4.Qt import QTextFormat
from PyQt4.Qt import QVariant
from PyQt4.Qt import QWidget
from PyQt4.Qt import Qt

class LNTextEdit(QFrame):

    class NumberBar(QWidget):

        def __init__(self, edit):
            QWidget.__init__(self, edit)

            self.edit = edit
            self.adjustWidth(1)

        def paintEvent(self, event):
            self.edit.numberbarPaint(self, event)
            QWidget.paintEvent(self, event)

        def adjustWidth(self, count):
            width = self.fontMetrics().width(unicode(count))
            if self.width() != width:
                self.setFixedWidth(width)

        def updateContents(self, rect, scroll):
            if scroll:
                self.scroll(0, scroll)
            else:
                # It would be nice to do
                # self.update(0, rect.y(), self.width(), rect.height())
                # But we can't because it will not remove the bold on the
                # current line if word wrap is enabled and a new block is
                # selected.
                self.update()


    class PlainTextEdit(QPlainTextEdit):

        def __init__(self, *args):
            QPlainTextEdit.__init__(self, *args)

            #self.setFrameStyle(QFrame.NoFrame)

            self.setFrameStyle(QFrame.NoFrame)
            self.highlight()
            #self.setLineWrapMode(QPlainTextEdit.NoWrap)

            self.cursorPositionChanged.connect(self.highlight)

        def highlight(self):
            hi_selection = QTextEdit.ExtraSelection()

            hi_selection.format.setBackground(self.palette().alternateBase())
            hi_selection.format.setProperty(QTextFormat.FullWidthSelection, QVariant(True))
            hi_selection.cursor = self.textCursor()
            hi_selection.cursor.clearSelection()

            self.setExtraSelections([hi_selection])

        def numberbarPaint(self, number_bar, event):
            font_metrics = self.fontMetrics()
            current_line = self.document().findBlock(self.textCursor().position()).blockNumber() + 1

            block = self.firstVisibleBlock()
            line_count = block.blockNumber()
            painter = QPainter(number_bar)
            painter.fillRect(event.rect(), self.palette().base())

            # Iterate over all visible text blocks in the document.
            while block.isValid():
                line_count += 1
                block_top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()

                # Check if the position of the block is out side of the visible
                # area.
                if not block.isVisible() or block_top >= event.rect().bottom():
                    break

                # We want the line number for the selected line to be bold.
                if line_count == current_line:
                    font = painter.font()
                    font.setBold(True)
                    painter.setFont(font)
                else:
                    font = painter.font()
                    font.setBold(False)
                    painter.setFont(font)

                # Draw the line number right justified at the position of the line.
                paint_rect = QRect(0, block_top, number_bar.width(), font_metrics.height())
                painter.drawText(paint_rect, Qt.AlignRight, unicode(line_count))

                block = block.next()

            painter.end()

    def __init__(self, *args):
        QFrame.__init__(self, *args)

        self.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken)

        self.edit = self.PlainTextEdit()
        self.number_bar = self.NumberBar(self.edit)

        hbox = QHBoxLayout(self)
        hbox.setSpacing(0)
        hbox.setMargin(0)
        hbox.addWidget(self.number_bar)
        hbox.addWidget(self.edit)

        self.edit.blockCountChanged.connect(self.number_bar.adjustWidth)
        self.edit.updateRequest.connect(self.number_bar.updateContents)

    def getText(self):
        return unicode(self.edit.toPlainText())

    def setText(self, text):
        self.edit.setPlainText(text)

    def isModified(self):
        return self.edit.document().isModified()

    def setModified(self, modified):
        self.edit.document().setModified(modified)

    def setLineWrapMode(self, mode):
        self.edit.setLineWrapMode(mode)

9 thoughts on “Better QPlainTextEdit With Line Numbers

  1. Hi. Thanks for the code. It is really helpful.

    One question: In the highlight function, shouldn’t the line

    hi_selection.format.setBackground(self.palette().alternateBase())

    be

    hi_selection[0].format.setBackground(self.palette().alternateBase())

    since

    QTextEdit.ExtraSelection()

    is supposed to return a QList??

    Thanks

  2. The docs do say it return a list… However, it doesn’t return an indexable list. The posted code works correctly. Using hi_selection[0] will result in:

    TypeError: 'ExtraSelection' object does not support indexing
    
  3. Thank YOU !!! This is the basis of a latex editor im trying to will … I Added bracket matching to your code….

    code removed by request of the author.

  4. sorry!!! the code has some errors! I am working on it… Id like to know how to post highlighted/indented code here!!!

  5. Thank you again, now for your quick reply! It might be better if you remove the code… I promise that once i finish the basic editor i’ll post the code again, but i was actually too happy cause the code worked fine that didnt give my self a chance to test it… and it is not completely correct… Sorry for my bad english.

  6. from PyQt4.QtGui import QPlainTextEdit, QWidget, QVBoxLayout, QApplication, \
                            QFileDialog, QMessageBox, QTextBrowser, QDockWidget, \
                            QMainWindow, QIcon, QHBoxLayout, QPainter, QGraphicsWidget, \
                            QFontMetrics, QFrame, QTextEdit, QTextFormat, \
                            QTextBlockUserData, QColor, QFont, QTextCursor, \
                            QSyntaxHighlighter, QTextCharFormat
    from PyQt4.QtCore import QString, SIGNAL as SIG, Qt, QEvent, QVariant,\
                             QRect, QRegExp, QThread
    import sys, os, re
    from subprocess import Popen, PIPE
    
    NUMBER_BAR_COLOR = QColor(180, 215, 140)
    
    BRACKET_MATCHED = QColor('blue')
    BRACKET_UNMATCHED = QColor('red')
    
    CURRENT_LINE_HL  = QColor(200, 205, 0, 70)
    
    class BracketsInfo:
        def __init__(self, character, position):
            self.character = character
            self.position  = position
    
    class TextBlockData(QTextBlockUserData):
        def __init__(self, parent = None):
            super(TextBlockData, self).__init__()
            self.braces = []
            self.valid = False
    
        def insert_brackets_info(self, info):
            self.valid = True
            self.braces.append(info)
    
        def isValid(self):
            return self.valid
    
    class LaTeX(QSyntaxHighlighter):
        command = QRegExp(r'\\(\w+)?')
        cmd_fmt = QTextCharFormat()
        cmd_fmt.setForeground(QColor('darkRed'))
    
        comment = QRegExp(r'%(.*)?')
        cmnt_fmt = QTextCharFormat()
        cmnt_fmt.setForeground(QColor('gray'))
        
        keywords = QRegExp(r'\\begin|\\end|\\label|\\ref|\\(sub)?(sub)?section')
        keyword_fmt = QTextCharFormat()
        keyword_fmt.setForeground(QColor('red'))
    
        rules = [(command, cmd_fmt),
                 (comment, cmnt_fmt),
                 (keywords, keyword_fmt)]
    
        def __init__(self, parent):
            super(LaTeX, self).__init__(parent)
    
        def highlightBlock(self, text):
            braces = QRegExp('(\{|\}|\(|\)|\[|\])')
            math_delimiters = QRegExp(r'(\\begin{equation\*?}|' +\
                                       r'\\begin{align\*?}|' +\
                                       r'\\begin{displaymath\*?})')
            begin_end = QRegExp(r'(\\begin{|\\end{|\\ref|\\label)')
            block_data = TextBlockData()
    
            index = braces.indexIn(text)
          
            while index >= 0:
                matched_brace = str(braces.capturedTexts()[0])
                info = BracketsInfo(matched_brace, index)
                block_data.insert_brackets_info(info)
                index = braces.indexIn(text, index + 1)
    
            self.setCurrentBlockUserData(block_data)
    
            for regex, fmt in LaTeX.rules:
                start = regex.indexIn(text, 0)
                while start >= 0:
                    length = regex.matchedLength()
                    self.setFormat(start, length, fmt)
                    start = regex.indexIn(text, start + length)
    
            self.setCurrentBlockState(0)
    
    class NumberBar(QWidget):
        def __init__(self, parent = None):
            super(NumberBar, self).__init__(parent)
            self.edit = parent
            layout = QVBoxLayout()
            self.setLayout(layout)
            self.edit.blockCountChanged.connect(self.update_width)
            self.edit.updateRequest.connect(self.update_on_scroll)
            self.update_width('1')
    
        def update_on_scroll(self, rect, scroll):
            if self.isVisible():
                if scroll:
                    self.scroll(0, scroll)
                else:
                    self.update()
    
        def update_width(self, string):
            width = self.fontMetrics().width(unicode(string)) + 20
            if self.width() != width:
                self.setFixedWidth(width)
    
        def paintEvent(self, event):
            if self.isVisible():
                block = self.edit.firstVisibleBlock()
                height = self.fontMetrics().height()
                number = block.blockNumber()
                painter = QPainter(self)
                painter.fillRect(event.rect(), NUMBER_BAR_COLOR)
                font = painter.font()
    
                current_block = self.edit.textCursor().block().blockNumber() + 1
    
                condition = True
                while block.isValid() and condition:
                    block_geometry = self.edit.blockBoundingGeometry(block)
                    offset = self.edit.contentOffset()
                    block_top = block_geometry.translated(offset).top()
                    number += 1
    
                    rect = QRect(0, block_top, self.width() - 5, height)
    
                    if number == current_block:
                        font.setBold(True)
                    else:
                        font.setBold(False)
    
                    painter.setFont(font)
                    painter.drawText(rect, Qt.AlignRight, '%i'%number)
    
                    if block_top > event.rect().bottom():
                        condition = False
    
                    block = block.next()
    
                painter.end()
    
    class QLaTeXEdit(QWidget):
        def __init__(self, parent = None):
            super(QLaTeXEdit, self).__init__(parent)
            # Editor Widget ...
            self.edit = QPlainTextEdit()
            self.edit.setFrameStyle(QFrame.NoFrame)
            self.extra_selections = []
            # Line Numbers ...
            self.numbers = NumberBar(self.edit)
            # Syntax Highlighter ...
            self.highlighter = LaTeX(self.edit.document())
    
            # Laying out...
            layout = QHBoxLayout()
            layout.setSpacing(1.5)
            layout.addWidget(self.numbers)
            layout.addWidget(self.edit)
            self.setLayout(layout)
            # Event Filter ...
            self.installEventFilter(self)
            self.edit.cursorPositionChanged.connect(self.check_brackets)
            
            # Brackets ExtraSelection ...
            self.left_selected_bracket  = QTextEdit.ExtraSelection()
            self.right_selected_bracket = QTextEdit.ExtraSelection()
    
        def set_numbers_visible(self, value = True):
            self.numbers.setVisible(False)
    
        def match_left(self, block, character, start, found):
            map = {'{': '}', '(': ')', '[': ']'}
    
            while block.isValid():
                data = block.userData()
                if data is not None:
                    braces = data.braces
                    N = len(braces)
    
                    for k in range(start, N):
                        if braces[k].character == character:
                            found += 1
    
                        if braces[k].character == map[character]:
                            if not found:
                                return braces[k].position + block.position()
                            else:
                                found -= 1
    
                    block = block.next()
                    start = 0
    
        def match_right(self, block, character, start, found):
            map = {'}': '{', ')': '(', ']': '['}
            
            while block.isValid():
                data = block.userData()
    
                if data is not None:
                    braces = data.braces
                    
                    if start is None:
                        start = len(braces)
                    for k in range(start - 1, -1, -1):
                        if braces[k].character == character:
                            found += 1
                        if braces[k].character == map[character]:
                            if found == 0:
                                return braces[k].position + block.position()
                            else:
                                found -= 1
                block = block.previous()
                start = None
    
        def check_brackets(self):
            left, right = QTextEdit.ExtraSelection(),\
                          QTextEdit.ExtraSelection()
    
            cursor = self.edit.textCursor()
            block = cursor.block()
            data = block.userData()
            previous, next = None, None
    
            if data is not None:
                position = cursor.position()
                block_position = cursor.block().position()
                braces = data.braces
                N = len(braces)
    
                for k in range(0, N):
                    if braces[k].position == position - block_position or\
                       braces[k].position == position - block_position - 1:
                        previous = braces[k].position + block_position
                        if braces[k].character in ['{', '(', '[']:
                            next = self.match_left(block,
                                                   braces[k].character,
                                                   k + 1, 0)
                        elif braces[k].character in ['}', ')', ']']:
                            next = self.match_right(block,
                                                    braces[k].character,
                                                    k, 0)
                        if next is None:
                            next = -1
    
            if next is not None and next > 0:
                if next = 0 and next >= 0:
                format = QTextCharFormat()
    
                cursor.setPosition(previous)
                cursor.movePosition(QTextCursor.NextCharacter,
                                    QTextCursor.KeepAnchor)
                
                format.setForeground(BRACKET_MATCHED)
                format.setBackground(QColor('white'))
                self.left_selected_bracket.format = format
                self.left_selected_bracket.cursor = cursor
    
                cursor.setPosition(next)
                cursor.movePosition(QTextCursor.NextCharacter,
                                    QTextCursor.KeepAnchor)
                
                format.setForeground(BRACKET_MATCHED)
                format.setBackground(QColor('white'))
                self.right_selected_bracket.format = format
                self.right_selected_bracket.cursor = cursor
    
        def paintEvent(self, event):
            highlighted_line = QTextEdit.ExtraSelection()
            highlighted_line.format.setBackground(CURRENT_LINE_HL)
            highlighted_line.format.setProperty(QTextFormat\
                                                     .FullWidthSelection,
                                                      QVariant(True))
            highlighted_line.cursor = self.edit.textCursor()
            highlighted_line.cursor.clearSelection()
            self.edit.setExtraSelections([highlighted_line,
                                          self.left_selected_bracket,
                                          self.right_selected_bracket])
    
        def document(self):
            return self.edit.document
    
        def getPlainText(self):
            return unicode(self.edit.toPlainText())
    
        def isModified(self):
            return self.edit.document().isModified()
    
        def setModified(self, modified):
            self.edit.document().setModified(modified)
    
        def setLineWrapMode(self, mode):
            self.edit.setLineWrapMode(mode)
    
        def clear(self):
            self.edit.clear()
    
        def setPlainText(self, *args, **kwargs):
            self.edit.setPlainText(*args, **kwargs)
    
        def setDocumentTitle(self, *args, **kwargs):
            self.edit.setDocumentTitle(*args, **kwargs)
    
        def eventFilter(self, object, event):
            if event.type() == QEvent.KeyPress:
                if event.key() == Qt.Key_Return:
                    QPlainTextEdit.event(self, event)
                    print 'return pressed'
                    return True
                if event.key() == Qt.Key_F6:
                    self.emit(SIG('pdflatex'))
                    return True
                if event.key() == Qt.Key_F7:
                    self.emit(SIG('pdfshow'))
                    return True
                if event.key() == Qt.Key_S and\
                   event.modifiers() == Qt.ControlModifier:
                    self.emit(SIG('save_document'))
                    return True
                if event.key() == Qt.Key_O and\
                   event.modifiers() == Qt.ControlModifier:
                    self.emit(SIG('open_document'))
                    return True
                if event.key() == Qt.Key_N and\
                   event.modifiers() == Qt.ControlModifier:
                    self.emit(SIG('new_document'))
                    return True
                if event.key() == Qt.Key_W and\
                   event.modifiers() == Qt.ControlModifier:
                    self.emit(SIG('close_document'))
                    return True
                else:
                    return False
            return False
    
        def set_number_bar_visible(self, value):
            self.numbers.setVisible(value)
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        win = QLaTeXEdit()
        win.show()
        app.exec_()
    
  7. This one is OK!!! but it’s kind of slow when there are several brackets in the same block… if you get a better idea ill be grateful… This implementation of QTextBlockUserData is actually from the QtQuarterly. As you see, the key bindings for compiling a document, opening, saving and stuff, are already defined by an event filter but the only emit signals and do nothing else.

Comments are closed.