PyQt5桌面应用开发(7):文本编辑+语法高亮与行号

PyQt5桌面应用系列

代码编辑和语法高亮的亿点点细节

接着上回的文件对话框,我们来看看代码编辑器和语法高亮的实现。文件打开和显示、文件编辑这是跟文本相关的用户界面的两个核心的功能,在严肃的桌面应用开发中,这是必不可少的。

  • 用户报表,例如:显示Log文件;
  • 用户交互,例如:编辑配置文件。

PyQt5提供了三个控件,继承关系如下图。

  • QTextEdit:支持HTML语法的控件;
    • QTextBrowser:只读,支持链接和跳转。
  • QPlainTextEdit:基于纯文本的控件。

这两个控件的主要不同在与文本布局计算的方式,实际上QPlainTextEdit实现了基于行的文本布局,其文本滚动则是基于段落的。

在这里插入图片描述

我们想要实现代码编辑器,那就必须不考虑采用HTML语法来格式化显示的文本,就算是HTML代码编辑器,也要用纯文本!所以我们选择QPlainTextEdit

QPlainTextEdit是一个高级的查看器/编辑器,支持纯文本。它被优化用于处理大型文档,并快速响应用户输入。 这个控件使用了和QTextEdit相同的技术和概念,但是它是为了纯文本处理而优化的。控件的文档是由段落组成的,段落是格式化的字符串,它会自动换行以适应控件的宽度。默认情况下,一个换行符表示一个段落。一个文档由零个或多个段落组成。段落由硬换行符分隔。段落中的每个字符都有自己的属性,例如字体和颜色。

鼠标光标的形状默认是Qt::IBeamCursor。可以通过viewport()cursor属性来改变。

作为用户报表的文本控件

文本采用setPlainText()设置或替换,该函数删除现有文本并用传递给setPlainText()的文本替换它。

文本可以使用QTextCursor类或使用insertPlainText()appendPlainText()paste()的便利函数插入。

默认情况下,文本编辑器在空格处换行以适应文本编辑器窗体。 setLineWrapMode()函数用于指定所需的换行方式,如果不需要任何换行,则为WidgetWidthNoWrap。如果使用单词换行到窗体宽度WidgetWidth,则可以使用setWordWrapMode()指定是否在空格处或任何位置中断。

find函数可以用于查找和选择文本中的字符串。

如果要限制QPlainTextEdit中段落的总数,例如在日志查看器中非常有用,则可以使用maximumBlockCount属性。 setMaximumBlockCount()appendPlainText()的组合将QPlainTextEdit变为日志文本的高效查看器。 可以使用centerOnScroll()属性减少滚动,从而使日志查看器更快。 可以以有限的方式格式化文本,要么使用语法突出显示器,要么使用appendHtml()附加html格式的文本。 虽然QPlainTextEdit不支持具有表格和浮动的复杂富文本呈现,但它支持您可能需要的日志查看器中的有限基于段落的格式。

作为编辑器的文本控件

编辑器首先也是一个展示文本的控件,所以上述的内容也适用于编辑器。

选择文本由QTextCursor类处理,该类提供了创建选择,检索文本内容或删除选择的功能。 您可以使用textCursor()方法检索与用户可见光标对应的对象。 如果要在QPlainTextEdit中设置选择,只需在QTextCursor对象上创建一个选择,然后使用setCursor()将该光标设置为可见光标。 可以使用copy()将选择复制到剪贴板,或使用cut()将其剪切到剪贴板。 可以使用selectAll()选择整个文本。

QPlainTextEdit组合了一个QTextDocument对象,可以使用document()方法检索该对象。 您还可以使用setDocument()设置自己的文档对象。 如果文本更改,则QTextDocument发出textChanged()信号,它还提供了一个isModified()函数,如果自加载以来文本已被修改或自上次调用setModified(),则返回true,参数为false。 此外,它提供了撤消和重做的方法。

代码编辑器的需求

  • QPlainTextEdit是一个纯文本编辑/查看器;
  • 它提供了很强大的编辑和查看功能;
  • 编辑对象由QTextDocument类提供。

实现代码编辑器,则主要有两个方面的内容:

  • 显示行号,高亮当前行;
  • 语法高亮。

这都有现成的参考例子,例如我们正在编的代码,用的是IDEA,它的代码编辑器就是这样的:

在这里插入图片描述

代码编辑1

这个地方,就是简单的实现行号显示和高亮当前行。从图中可以看出,编辑器在编辑区域左侧的区域中显示行号。 编辑器将突出显示包含光标的行。

参考官方代码,我们实现继承自QPlainTextEditCodeEditor类;增加LineNumberArea类,用于显示行号。LineNumberArea类继承自QWidget,并与CodeEditor类形成组合关系,也就是作为一个成员变量。

下面就是LineNumberArea的代码,这个类与一个Editor联系在一起,当Editor的块计数变化、更新的时候,就调用这里的两个方法来计算宽度、行数,更新外观。 这里重载了QWidget.paintEvent方法,来设置相应的字体、背景,显示行数,可以看到,这里的行数从1开始计数。

painter.drawText(paint_rect, Qt.AlignRight, str(block_number + 1))
from PyQt5.QtCore import Qt
from PyQt5.QtCore import QRect
from PyQt5.QtGui import QFont, QColor, QPainter
from PyQt5.QtWidgets import QWidget
class LineNumberArea(QWidget):
    def __init__(self, editor):
        QWidget.__init__(self, editor)
        self.editor = editor
        self.editor.blockCountChanged.connect(self.update_width)
        self.editor.updateRequest.connect(self.update_contents)
        self.font = QFont()
        self.numberBarColor = QColor("#e8e8e8")

    def paintEvent(self, event):
        # Override paintEvent to draw the line numbers
        painter = QPainter(self)
        painter.fillRect(event.rect(), self.numberBarColor)

        block = self.editor.firstVisibleBlock()

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

            # Check if the position of the block is outside 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 block_number == self.editor.textCursor().blockNumber():
                self.font.setBold(True)
                painter.setPen(QColor("#000000"))
            else:
                self.font.setBold(False)
                painter.setPen(QColor("#717171"))
            painter.setFont(self.font)

            # Draw the line number right justified at the position of the line.
            paint_rect = QRect(0, int(block_top), self.width(), self.editor.fontMetrics().height())
            painter.drawText(paint_rect, Qt.AlignRight, str(block_number + 1))

            block = block.next()

        painter.end()

        QWidget.paintEvent(self, event)

    # 根据文档的总行数来计算宽度
    def get_width(self):
        count = self.editor.blockCount()
        width = self.fontMetrics().width(str(count)) + 10
        return width

    # 设置宽度
    def update_width(self):
        width = self.get_width()
        if self.width() != width:
            self.setFixedWidth(width)
            self.editor.setViewportMargins(width, 0, 0, 0);

    # 更行内容
    def update_contents(self, rect, scroll):
        if scroll:
            self.scroll(0, scroll)
        else:
            self.update(0, rect.y(), self.width(), rect.height())

        if rect.contains(self.editor.viewport().rect()):
            font_size = self.editor.currentCharFormat().font().pointSize()
            self.font.setPointSize(font_size)
            self.font.setStyle(QFont.StyleNormal)
            self.update_width()

有了上面这个行数区域之后,就可以实现一个代码编辑器,是QPlainTextEdit的子类。在构造函数里面,把字体、背景什么的设置了,然后把语法高亮、当前行高亮、显示行号设置好。最后,把控件的光标位置变化信号连接到高亮当前行的槽函数。整个逻辑非常简单。

from PyQt5.QtCore import QRect
from PyQt5.QtGui import QFont,  QTextFormat
from PyQt5.QtWidgets import QPlainTextEdit, QTextEdit
class QCodeEditor(QPlainTextEdit):
    def __init__(self, display_line_numbers=True, highlight_current_line=True,
                 syntax_high_lighter=None, *args):
        """
        Parameters
        ----------
        display_line_numbers : bool
            switch on/off the presence of the lines number bar
        highlight_current_line : bool
            switch on/off the current line highlighting
        syntax_high_lighter : QSyntaxHighlighter
            should be inherited from QSyntaxHighlighter

        """
        super(QCodeEditor, self).__init__()

        self.setFont(QFont("Microsoft YaHei UI Light", 11))
        self.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.DISPLAY_LINE_NUMBERS = display_line_numbers

        if display_line_numbers:
            self.number_bar = self.LineNumberArea(self)

        if highlight_current_line:
            self.currentLineNumber = None
            self.currentLineColor = self.palette().alternateBase()
            # self.currentLineColor = QColor("#e8e8e8")
            self.cursorPositionChanged.connect(self.highlight_current_line)

        if syntax_high_lighter is not None:  # add highlighter to text document
            self.highlighter = syntax_high_lighter(self.document())

    def resizeEvent(self, *e):
        """overload resizeEvent handler"""

        if self.DISPLAY_LINE_NUMBERS:  # resize LineNumberArea widget
            cr = self.contentsRect()
            rec = QRect(cr.left(), cr.top(), self.number_bar.get_width(), cr.height())
            self.number_bar.setGeometry(rec)

        QPlainTextEdit.resizeEvent(self, *e)

    def highlight_current_line(self):
        new_current_line_number = self.textCursor().blockNumber()
        if new_current_line_number != self.currentLineNumber:
            self.currentLineNumber = new_current_line_number
            hi_selection = QTextEdit.ExtraSelection()
            hi_selection.format.setBackground(self.currentLineColor)
            hi_selection.format.setProperty(QTextFormat.FullWidthSelection, True)
            hi_selection.cursor = self.textCursor()
            hi_selection.cursor.clearSelection()
            self.setExtraSelections([hi_selection])

这里的self.highlighter = syntax_high_lighter(self.document())就是设置语法高亮的部分。

语法高亮2

语法高亮的核心有两个类:

  • QSyntaxHighlighter
  • QTextChartFormat

在这里插入图片描述
这个类提供了各种接口,最为核心的方法、也就是这里要重载的就是highlighBlock。而这个方法里面最重要的一个用于设置显示格式的是QTextChartFormat。这个类的对象要作为QSyntexHighlighter.setFormat(index, length, format)的第三个参数来给一段文字设置格式。整个的实现也很简单,定义一些需要特殊显示的类别,给每个类别设置格式,然后用一个正则表达式进行匹配。

在这里插入图片描述

整个程序的逻辑也非常简单。rules是一个列表,列表的每个项是一个三元组,(正则表达式,匹配项序号,格式QTextChartFormat)。辅助函数format_syn设置颜色和字体外形。

构造函数的最后,构造列表:

# Build a QRegExp for each pattern
self.rules = [(QtCore.QRegExp(pat), index, fmt)
              for (pat, index, fmt) in rules]

而在重载的函数highlighBlock中,遍历列表,设置格式。

from PyQt5 import QtGui, QtCore


def format_syn(color, style=''):
    """Return a QTextCharFormat with the given attributes.
    """
    _color = QtGui.QColor()
    _color.setNamedColor(color)

    _format = QtGui.QTextCharFormat()
    _format.setForeground(_color)
    if 'bold' in style:
        _format.setFontWeight(QtGui.QFont.Bold)
    if 'italic' in style:
        _format.setFontItalic(True)

    return _format


# Syntax styles that can be shared by all languages
STYLES = {
    'keyword': format_syn('blue'),
    'operator': format_syn('red'),
    'brace': format_syn('darkGray'),
    'defclass': format_syn('black', 'bold'),
    'string': format_syn('magenta'),
    'string2': format_syn('darkMagenta'),
    'comment': format_syn('darkGreen', 'italic'),
    'self': format_syn('black', 'italic'),
    'numbers': format_syn('brown'),
}


class PythonHighlighter(QtGui.QSyntaxHighlighter):
    """Syntax highlighter for the Python language.
    """
    # Python keywords
    keywords = [
        'and', 'assert', 'break', 'class', 'continue', 'def',
        'del', 'elif', 'else', 'except', 'exec', 'finally',
        'for', 'from', 'global', 'if', 'import', 'in',
        'is', 'lambda', 'not', 'or', 'pass', 'print',
        'raise', 'return', 'try', 'while', 'yield',
        'None', 'True', 'False',
    ]

    # Python operators
    operators = [
        r'=',
        # Comparison
        r'==', r'!=', r'<', r'<=', r'>', r'>=',
        # Arithmetic
        r'\+', r'-', r'\*', r'/', r'//', r'\%', r'\*\*',
        # In-place
        r'\+=', r'-=', r'\*=', r'/=', r'\%=',
        # Bitwise
        r'\^', r'\|', r'\&', r'\~', r'>>', r'<<',
    ]

    # Python braces
    braces = [
        r'\{', r'\}', r'\(', r'\)', r'\[', r'\]',
    ]

    def __init__(self, parent: QtGui.QTextDocument) -> None:
        super().__init__(parent)

        # Multi-line strings (expression, flag, style)
        self.tri_single = (QtCore.QRegExp("'''"), 1, STYLES['string2'])
        self.tri_double = (QtCore.QRegExp('"""'), 2, STYLES['string2'])

        rules = []

        # Keyword, operator, and brace rules
        rules += [(r'\b%s\b' % w, 0, STYLES['keyword'])
                  for w in PythonHighlighter.keywords]
        rules += [(r'%s' % o, 0, STYLES['operator'])
                  for o in PythonHighlighter.operators]
        rules += [(r'%s' % b, 0, STYLES['brace'])
                  for b in PythonHighlighter.braces]

        # All other rules
        rules += [
            # 'self'
            (r'\bself\b', 0, STYLES['self']),

            # 'def' followed by an identifier
            (r"\bdef\b\s*(\w+)", 1, STYLES['defclass']),
            # 'class' followed by an identifier
            (r'\bclass\b\s*(\w+)', 1, STYLES['defclass']),

            # Numeric literals
            (r'\b[+-]?[0-9]+[lL]?\b', 0, STYLES['numbers']),
            (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, STYLES['numbers']),
            (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, STYLES['numbers']),

            # Double-quoted string, possibly containing escape sequences
            (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']),
            # Single-quoted string, possibly containing escape sequences
            (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']),

            # From '#' until a newline
            (r'#[^\n]*', 0, STYLES['comment']),
        ]

        # Build a QRegExp for each pattern
        self.rules = [(QtCore.QRegExp(pat), index, fmt)
                      for (pat, index, fmt) in rules]

    def highlightBlock(self, text):
        """Apply syntax highlighting to the given block of text.
        """
        self.tripleQuoutesWithinStrings = []
        # Do other syntax formatting
        for expression, nth, format in self.rules:
            index = expression.indexIn(text, 0)
            if index >= 0:
                # if there is a string we check
                # if there are some triple quotes within the string
                # they will be ignored if they are matched again
                if expression.pattern() in [r'"[^"\\]*(\\.[^"\\]*)*"', r"'[^'\\]*(\\.[^'\\]*)*'"]:
                    innerIndex = self.tri_single[0].indexIn(text, index + 1)
                    if innerIndex == -1:
                        innerIndex = self.tri_double[0].indexIn(text, index + 1)

                    if innerIndex != -1:
                        tripleQuoteIndexes = range(innerIndex, innerIndex + 3)
                        self.tripleQuoutesWithinStrings.extend(tripleQuoteIndexes)

            while index >= 0:
                # skipping triple quotes within strings
                if index in self.tripleQuoutesWithinStrings:
                    index += 1
                    expression.indexIn(text, index)
                    continue

                # We actually want the index of the nth match
                index = expression.pos(nth)
                length = len(expression.cap(nth))
                self.setFormat(index, length, format)
                index = expression.indexIn(text, index + length)

        self.setCurrentBlockState(0)

        # Do multi-line strings
        in_multiline = self.match_multiline(text, *self.tri_single)
        if not in_multiline:
            in_multiline = self.match_multiline(text, *self.tri_double)

    def match_multiline(self, text, delimiter, in_state, style):
        """Do highlight of multi-line strings. ``delimiter`` should be a
        ``QRegExp`` for triple-single-quotes or triple-double-quotes, and
        ``in_state`` should be a unique integer to represent the corresponding
        state changes when inside those strings. Returns True if we're still
        inside a multi-line string when this function is finished.
        """
        # If inside triple-single quotes, start at 0
        if self.previousBlockState() == in_state:
            start = 0
            add = 0
        # Otherwise, look for the delimiter on this line
        else:
            start = delimiter.indexIn(text)
            # skipping triple quotes within strings
            if start in self.tripleQuoutesWithinStrings:
                return False
            # Move past this match
            add = delimiter.matchedLength()

        # As long as there's a delimiter match on this line...
        while start >= 0:
            # Look for the ending delimiter
            end = delimiter.indexIn(text, start + add)
            # Ending delimiter on this line?
            if end >= add:
                length = end - start + add + delimiter.matchedLength()
                self.setCurrentBlockState(0)
            # No; multi-line string
            else:
                self.setCurrentBlockState(in_state)
                length = len(text) - start + add
            # Apply formatting
            self.setFormat(start, length, style)
            # Look for the next match
            start = delimiter.indexIn(text, start + length)

        # Return True if still inside a multi-line string, False otherwise
        if self.currentBlockState() == in_state:
            return True
        else:
            return False

最后,在要用控件的时候,直接调用下面的代码就ok。

content_edit = QCodeEditor(display_line_numbers=True,
                                    highlight_current_line=True,
                                    syntax_high_lighter=PythonHighlighter)

小结

  1. 高亮代码重载QSyntaxHighlighter.highlighBlock,调用QSyntexHighlighter.setFormat(index, length, format)设置格式;
  2. 第三个参数即为文本格式,是QTextCharFormat的对象;
  3. 显示行数要自行定义一个QWidget,实现几个信号槽就可以。

  1. 官方网站:代码编辑器 ↩︎

  2. 官方网站:语法高亮 ↩︎

  • 1
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大福是小强

除非你钱多烧得慌……

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值