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