* Better QPlainTextEdit With Line Numbers

Posted on August 19th, 2009 by John. Filed under programming.


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)

Tags: , ,



8 Responses to “Better QPlainTextEdit With Line Numbers”

  1. Dave Berk Says:

    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. John Says:

    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. iharob Says:

    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. iharob Says:

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

  5. John Says:

    <pre lang=”python”>
    code…
    </pre>

    I add it for you in the code comment.

  6. iharob Says:

    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.

  7. iharob Says:
    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 &gt;= 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 &gt;= 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 &gt; 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 &gt; 0:
                if next = 0 and next &gt;= 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_()
  8. iharob Says:

    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.

Trackback URI | Comments RSS

Leave a Reply