本文目录
- PyQt5桌面应用系列
- 代码编辑和语法高亮的亿点点细节
- 作为用户报表的文本控件
- 作为编辑器的文本控件
- 代码编辑器的需求
- 代码编辑[^1]
- 语法高亮[^2]
- 小结
PyQt5桌面应用系列
- PyQt5桌面应用开发(1):需求分析
- PyQt5桌面应用开发(2):事件循环
- PyQt5桌面应用开发(3):并行设计
- PyQt5桌面应用开发(4):界面设计
- PyQt5桌面应用开发(5):对话框
- PyQt5桌面应用开发(6):文件对话框
- PyQt5桌面应用开发(7):文本编辑+语法高亮与行号
代码编辑和语法高亮的亿点点细节
接着上回的文件对话框,我们来看看代码编辑器和语法高亮的实现。文件打开和显示、文件编辑这是跟文本相关的用户界面的两个核心的功能,在严肃的桌面应用开发中,这是必不可少的。
- 用户报表,例如:显示Log文件;
- 用户交互,例如:编辑配置文件。
PyQt5提供了三个控件,继承关系如下图。
QTextEdit:支持HTML语法的控件;QTextBrowser:只读,支持链接和跳转。
QPlainTextEdit:基于纯文本的控件。
这两个控件的主要不同在与文本布局计算的方式,实际上QPlainTextEdit实现了基于行的文本布局,其文本滚动则是基于段落的。

我们想要实现代码编辑器,那就必须不考虑采用HTML语法来格式化显示的文本,就算是HTML代码编辑器,也要用纯文本!所以我们选择QPlainTextEdit。
QPlainTextEdit是一个高级的查看器/编辑器,支持纯文本。它被优化用于处理大型文档,并快速响应用户输入。 这个控件使用了和QTextEdit相同的技术和概念,但是它是为了纯文本处理而优化的。控件的文档是由段落组成的,段落是格式化的字符串,它会自动换行以适应控件的宽度。默认情况下,一个换行符表示一个段落。一个文档由零个或多个段落组成。段落由硬换行符分隔。段落中的每个字符都有自己的属性,例如字体和颜色。
鼠标光标的形状默认是Qt::IBeamCursor。可以通过viewport()的cursor属性来改变。
作为用户报表的文本控件
文本采用setPlainText()设置或替换,该函数删除现有文本并用传递给setPlainText()的文本替换它。
文本可以使用QTextCursor类或使用insertPlainText(),appendPlainText()或paste()的便利函数插入。
默认情况下,文本编辑器在空格处换行以适应文本编辑器窗体。 setLineWrapMode()函数用于指定所需的换行方式,如果不需要任何换行,则为WidgetWidth或NoWrap。如果使用单词换行到窗体宽度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
这个地方,就是简单的实现行号显示和高亮当前行。从图中可以看出,编辑器在编辑区域左侧的区域中显示行号。 编辑器将突出显示包含光标的行。
参考官方代码,我们实现继承自QPlainTextEdit的CodeEditor类;增加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
语法高亮的核心有两个类:
QSyntaxHighlighterQTextChartFormat

这个类提供了各种接口,最为核心的方法、也就是这里要重载的就是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)
小结
- 高亮代码重载
QSyntaxHighlighter.highlighBlock,调用QSyntexHighlighter.setFormat(index, length, format)设置格式; - 第三个参数即为文本格式,是
QTextCharFormat的对象; - 显示行数要自行定义一个
QWidget,实现几个信号槽就可以。
官方网站:代码编辑器 ↩︎
官方网站:语法高亮 ↩︎


















![[架构之路-182]-《软考-系统分析师》-19- 系统可靠性分析与设计 - 概览](https://img-blog.csdnimg.cn/img_convert/fb33eab3bd3bfc0866569866459b8c01.png)