【PC桌面自动化测试工具开发笔记】(四)用QTextEdit实现仿AirtestIDE的代码编辑界面

前言

测试组内一直在推进QT测试自动化,原本使用的AirtestIDE并不能完全满足组内自动化需求。出于测试上班闲着也是摸鱼的态度,我参考CSDN各大佬的代码,组装了一个符合我预期的代码编辑界面。
在之前写的Pyqt5实现带行号显示的QTextEdit文章中已经重写QTextEdit类实现了带行号标记的富文本编辑框,继续修改完善类代码实现所有需求点。

  1. 支持富文本显示与纯代码显示的切换。
  2. 显示效果调整,包括光标所在行显示效果,选中文本显示效果。
  3. 实现Python语法高亮。
  4. 实现代码自动补全。
  5. 实现快捷键调整代码缩进、注释代码。

富文本显示与纯代码切换

用过AirtestIDE的友友应该知道AirtestIDE创建的测试脚本.py文件与图片资源文件是存储在同一路径下,即以.air为结尾的文件夹内,代码编辑界面支持图片显示,可以切换图片显示/纯代码显示。Pyqt5的QTextEdit是支持富文本显示的,我们要做的读取python代码→检测每一行是否包含图片名→替换代码显示。
这里用一个类变量show_image去控制富文本显示/纯代码显示,用类实例变量self.air_case_file_path去存储当前编辑的脚本路径。后面结合QPushButton和QListWidget就可以实现显示模式切换和测试脚本切换了。
下面是实现读取代码内容并显示文本内容、图片的类函数:

    def show_text_pic(self, air_case_file_path):
        """
        读取代码内容并显示文本内容、图片
        """
        self.air_case_file_path=air_case_file_path
        self.setUndoRedoEnabled(False)
        if os.path.isfile(air_case_file_path):
            air_case_path = air_case_file_path[:air_case_file_path.rfind('\\')]
            self.clear()
            pic_list = [file for file in os.listdir(air_case_path) if file.endswith(".png")]
            with open(air_case_file_path, 'r', encoding='utf-8') as wra:
                text_list = wra.readlines()
                for row in range(len(text_list)):
                    line = text_list[row].strip("\n")
                    for pic in pic_list:
                        if pic in line:
                            if self.show_image:
                                line = line.replace(pic, "<img src='" + os.path.join(air_case_path, pic) + "'/>")
                    self.append(line)  # TODO:无法在开头添加空行
            cursor = self.textCursor()
            cursor.movePosition(QTextCursor.Start)
            self.setTextCursor(cursor)
        self.setUndoRedoEnabled(True)

图片显示/纯代码的显示可以通过改变show_image的值,再重新调用show_text_pic()实现,也可以通过读取当前QTextEdit的document内容去实现切换:
对字符串进行一顿操作

def string_to_html_filter(text: str) -> str:
    """
    特殊字符转换
    :param text: 原始文本
    :return: 转换后的文本
    """
    text = text.replace("&", "&amp;")
    text = text.replace(">", "&gt;")
    text = text.replace("<", "&lt;")
    text = text.replace("\"", "&quot;")
    text = text.replace("\'", "&#39;")
    text = text.replace(" ", "&nbsp;")
    text = text.replace("\n", "<br>")
    text = text.replace("\r", "<br>")
    return text


def get_img_from_text(plain_line, air_case_path, img_name):
    """
    纯文本转换为富文本图片显示
    :param plain_line: 纯文本
    :param air_case_path: 用例路径
    :param img_name: 图片名称
    :return: 富文本内容
    """
    plain_line = string_to_html_filter(plain_line)
    html_line = plain_line.replace(img_name, "<img src='" + os.path.join(air_case_path, img_name) + "'/>")
    return html_line


def get_img_list_from_text(text_list: list[str], air_case_path: str, pic_list: list[str]) -> list[tuple]:
    """
    根据文本列表获取图片列表
    :param text_list: 文本列表
    :param air_case_path: 用例地址
    :param pic_list: 图片列表
    :return: (图片路径,匹配文本行,行号)...列表
    """
    image_list = []
    for row in range(len(text_list)):
        line = text_list[row].strip("\n")
        for pic in pic_list:
            img_path = os.path.join(air_case_path, pic)
            if pic in line:
                image_list.append((img_path, line, row))
    return image_list


def search_img_in_html(html_text):
    """
    从html内容中查找img
    :param html_text: html富文本
    :return: row_list
    """
    content = html_text.split("\n")[4:]  # 跳过前四行
    row_list = [i for i in range(len(content)) if "<img src=" in content[i]]  # 返回图片所在行号
    return row_list


def get_img_from_html(html_text, row):
    """
    从html中提取图片路径,图片名
    :param html_text: html文本
    :param row: 需要处理的行号
    :return: 图片路径,图片名
    """
    html_content = html_text.split("\n")[4:]  # 跳过前四行
    html_line = html_content[row]  # 图片所在行
    img_path = html_line[
               html_line.find("<img src=") + len("<img src=") + 1:html_line.rfind('.png') + len(
                   '.png')]  # 从html行内容提取图片信息
    img_name = img_path[img_path.rfind('\\') + 1:]
    return img_path, img_name


def get_text_from_img(plain_line, html_text, row):
    """
    将图片转换为文本
    :param plain_line: 获取无法解析图片的纯文本信息
    :param html_text: html富文本
    :param row: 需要处理的行号
    :return: 处理结果
    """
    img_name = get_img_from_html(html_text, row)[1]
    value = plain_line.replace('', img_name)  # 替换图片文本内容
    return value

以下是实现显示模式切换的类函数:

    def change_show_mode(self):
        """改变文本显示模式"""
        self.show_image = not self.show_image
        air_case_file_path = self.air_case_file_path
        if os.path.isfile(air_case_file_path):
            self.setUndoRedoEnabled(False)
            text_changed = self.document().isModified()  # 获取文本修改状态
            if self.show_image:  # 显示图片
                text_list = self.toPlainText().split("\n")
                air_case_path = air_case_file_path[:air_case_file_path.rfind("\\")]
                pic_list = [file for file in os.listdir(air_case_path) if file.endswith(".png")]
                image_list = get_img_list_from_text(text_list, air_case_path, pic_list)
                self.clear()
                for row in range(len(text_list)):
                    line = text_list[row]
                    for img in image_list:
                        if img[2] == row:
                            img_path = img[0]
                            img_name = img_path[img_path.rfind("\\") + 1:]
                            line = get_img_from_text(line, air_case_path, img_name)
                    self.append(line)
            else:  # 显示纯文本
                row_list = search_img_in_html(self.toHtml())
                content = ""
                for row in range(self.document().blockCount()):
                    text_line = self.document().findBlockByLineNumber(row).text()
                    if row in row_list:
                        text_line = get_text_from_img(text_line, self.toHtml(), row)
                    if content:
                        content += "\n" + text_line
                    else:
                        content += text_line
                self.clear()
                self.setText(content)
            self.setUndoRedoEnabled(True)
            if not text_changed:  # 未修改,重置文本修改状态
                self.document().setModified(False)
            else:
                self.document().setModified(True)

高亮光标所在行

QT官方示例中有可参考的C++代码,不再赘述,绑定cursorPositionChanged信号。

    def update_text_cursor(self):
        """光标改变时更新光标所在行样式"""
        if not self.document().isEmpty():
            selection = QTextEdit.ExtraSelection()
            line_color = QColor(55, 56, 49)
            selection.format.setBackground(line_color)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)
            selection.cursor = self.textCursor()
            selection.cursor.clearSelection()
            if self.textCursor().selection().isEmpty():  # 添加判断使选择文本功能正常
                self.setExtraSelections([selection])
            else:
                self.setExtraSelections([])
        else:
            self.setExtraSelections([])

选中文本显示效果调整

参考文章:QTextEdit的几种高亮设置(选中文本、关键字)

        pt = QPalette()
        pt.setBrush(QPalette.Highlight, QColor(73, 72, 62))
        pt.setBrush(QPalette.HighlightedText, QBrush(Qt.NoBrush))
        self.setPalette(pt)

Python语法高亮

参考文章:python3+PyQt5 实现理解python语法并做高亮显示的纯文本编辑器

completer_highlight.py
存放自动补全、语法高亮关键字。common.Application存放我的测试框架公共方法,请根据自己的实际情况修改。

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
import builtins
import inspect

import common.Application


def get_classes(arg):
    """获取模块中的所有类名"""
    classes = [name for (name, _) in inspect.getmembers(arg, inspect.isclass)]
    return classes


def get_functions(arg):
    """获取模块中的所有函数名"""
    functions = [name for (name, _) in inspect.getmembers(arg, inspect.isfunction)]
    return functions


def get_builtins():
    """获取builtins"""
    builtins_builtins = [name for (name, _) in inspect.getmembers(builtins, inspect.isbuiltin)]
    return builtins_builtins


KEYWORDS = ["and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except", "exec",
            "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "not", "or", "pass",
            "raise", "return", "try", "while", "with", "yield"]
BUILTINS_builtins = get_builtins()
BUILTINS_classes = get_classes(builtins)
BUILTINS = BUILTINS_builtins + BUILTINS_classes
CONSTANTS = ["False", "True", "None", "NotImplemented", "Ellipsis"]
CLASSES = get_classes(common.Application)
FUNCTIONS = get_functions(common.Application)
FUNCTIONS_DICT = {"鼠标-左键": "touch($CURSOR$)", "鼠标-右键": "touch($CURSOR$, right_click = True)",
                  "鼠标-双击": "double_click($CURSOR$)",
                  "键盘输入文本": "text($CURSOR$)",
                  "等待": "sleep($CURSOR$)", "等待目标出现": "wait($CURSOR$)", "等待目标消失": "exists_wait($CURSOR$)",
                  "判断目标是否存在": "exists($CURSOR$)",
                  "断言目标存在": "assert_exists($CURSOR$)", "断言目标不存在": "assert_not_exists($CURSOR$)",
                  "数据读取-默认": "input_data = get_data(__file__)",
                  "数据读取-指定路径": "input_data = get_data($CURSOR$)"}

下面是Python语法高亮的实现代码,对原文章代码做了一定的修改,配色是照搬的Pycharm Monokai。

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
import sys

from PyQt5.QtCore import QRegExp, Qt
from PyQt5.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QCursor
from PyQt5.QtWidgets import QApplication

from completer_highlight import KEYWORDS, BUILTINS, CONSTANTS, FUNCTIONS


class PythonHighlighter(QSyntaxHighlighter):
    """python语法高亮"""
    Rules = []
    Formats = {}

    def __init__(self, parent=None):
        super(PythonHighlighter, self).__init__(parent)
        self.initializeFormats()
        OPERATORS = ["[+]", "[-]", "[*]", "[/]", "[<]", "[>]", "[=]", "[:]", "[@]"]
        PythonHighlighter.Rules.append((QRegExp("|".join([r"\b%s\b" % keyword for keyword in KEYWORDS])), "keyword"))
        PythonHighlighter.Rules.append((QRegExp("|".join([r"\b%s\b" % builtin for builtin in BUILTINS])), "builtin"))
        PythonHighlighter.Rules.append(
            (QRegExp("|".join([r"\b%s\b" % constant for constant in CONSTANTS])), "constant"))
        PythonHighlighter.Rules.append(
            (QRegExp("|".join(OPERATORS)), "operator"))
        PythonHighlighter.Rules.append(
            (QRegExp("|".join([r"\b%s\b" % function for function in FUNCTIONS])), "function"))
        PythonHighlighter.Rules.append((QRegExp(
            r"\b[0-9]+[lL]?\b"
            r"|\b0[xX][0-9A-Fa-f]+[lL]?\b"
            r"|\b[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"),
                                        "number"))
        PythonHighlighter.Rules.append((QRegExp(r"\bPyQt5\b|\bQt?[A-Z][a-z]\w+\b"), "pyqt"))
        PythonHighlighter.Rules.append((QRegExp(r"""\n\s*@\w*|^\s*@\w*"""), "decorator"))
        stringRe = QRegExp(r"""(?:'[^']*'|"[^"]*")""")
        stringRe.setMinimal(True)
        PythonHighlighter.Rules.append((stringRe, "string"))
        self.stringRe = QRegExp(r"""(:?"["]".*"["]"|'''.*''')""")
        self.stringRe.setMinimal(True)
        PythonHighlighter.Rules.append((self.stringRe, "string"))
        self.tripleSingleRe = QRegExp(r"""'''(?!")""")
        self.tripleDoubleRe = QRegExp(r'''"""(?!')''')

    def initializeFormats(self):
        baseFormat = QTextCharFormat()
        baseFormat.setFontFamily("Consolas")
        for name, color in (
                ("normal", QColor(Qt.white)), ("keyword", QColor(102, 217, 239)), ("builtin", QColor(102, 217, 239)),
                ("constant", QColor(102, 217, 239)), ("decorator", QColor(166, 226, 46)),
                ("comment", QColor(117, 113, 94)), ("string", QColor(230, 219, 116)),
                ("operator", QColor(249, 38, 114)), ("number", QColor(174, 129, 255)),
                ("error", QColor(Qt.darkRed)), ("function", QColor(102, 217, 239)),
                ("pyqt", QColor(Qt.darkCyan))):
            format = QTextCharFormat(baseFormat)
            format.setForeground(color)
            if name in ("keyword", "decorator"):
                format.setFontWeight(QFont.Bold)
            PythonHighlighter.Formats[name] = format

    def highlightBlock(self, text):
        NORMAL, TRIPLESINGLE, TRIPLEDOUBLE, ERROR = range(4)
        textLength = len(text)
        prevState = self.previousBlockState()
        self.setFormat(0, textLength, PythonHighlighter.Formats["normal"])
        if text.startswith("Traceback") or text.startswith("Error: "):
            self.setCurrentBlockState(ERROR)
            self.setFormat(0, textLength, PythonHighlighter.Formats["error"])
            return
        if (prevState == ERROR and not (text.startswith(sys.ps1) or text.startswith("#"))):
            self.setCurrentBlockState(ERROR)
            self.setFormat(0, textLength, PythonHighlighter.Formats["error"])
            return
        for regex, format in PythonHighlighter.Rules:
            i = regex.indexIn(text)
            while i >= 0:
                length = regex.matchedLength()
                self.setFormat(i, length, PythonHighlighter.Formats[format])
                i = regex.indexIn(text, i + length)
        # Slow but good quality highlighting for comments. For more
        # speed, comment this out and add the following to __init__:
        # PythonHighlighter.Rules.append((QRegExp(r"#.*"), "comment"))
        if not text:
            pass
        elif text[0] == "#":
            self.setFormat(0, len(text), PythonHighlighter.Formats["comment"])
        else:
            stack = []
            for i, c in enumerate(text):
                if c in ('"', "'"):
                    if stack and stack[-1] == c:
                        stack.pop()
                    else:
                        stack.append(c)
                elif c == "#" and len(stack) == 0:
                    self.setFormat(i, len(text), PythonHighlighter.Formats["comment"])
                    break

        self.setCurrentBlockState(NORMAL)
        if self.stringRe.indexIn(text) != -1:
            return
        # This is fooled by triple quotes inside single quoted strings
        for i, state in ((self.tripleSingleRe.indexIn(text), TRIPLESINGLE),
                         (self.tripleDoubleRe.indexIn(text), TRIPLEDOUBLE)):
            if self.previousBlockState() == state:
                if i == -1:
                    i = len(text)
                    self.setCurrentBlockState(state)
                self.setFormat(0, i + 3, PythonHighlighter.Formats["string"])
            elif i > -1:
                self.setCurrentBlockState(state)
                self.setFormat(i, len(text), PythonHighlighter.Formats["string"])

    def rehighlight(self):
        QApplication.setOverrideCursor(QCursor(
            Qt.WaitCursor))
        QSyntaxHighlighter.rehighlight(self)
        QApplication.restoreOverrideCursor()

这部分代码可以改一改正则匹配规则给后面搭建的Logs日志输出窗口用。

自动补全

参考文章:PyQt5 QTextEdit自动补全,QTextEdit使用QCompleter

搬了博主的绝大部分代码,修改的点主要是关键字列表的切换和匹配当前光标左侧的word。最后修改效果见源码展示吧。

编辑器快捷键与代码粘贴自动转换

  1. 需要实现Ctrl+/注释代码取消注释代码。
  2. Tab调整代码缩进,Tab+Shift取消代码缩进。
  3. 都需要支持多行代码的处理操作。
  4. 粘贴代码时需要考虑复制的内容与当前显示模式是否一致,粘贴时转换为富文本/纯代码使显示模式一致。

重写类的keyPressEvent(),这里留了copy_signal和paste_signal两个信号,后面的文章实现不同测试脚本间代码内容拷贝时自动拷贝图片资源会用到。
下面放源码。

源码

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
import os
import re
import sys

from PyQt5.QtCore import pyqtSignal, Qt, QSize, QRect, QPoint, QStringListModel, QRegExp
from PyQt5.QtGui import QColor, QPainter, QTextCursor, QTextFormat, QFont, QPalette, QBrush
from PyQt5.QtWidgets import QTextEdit, QCompleter, QWidget, QApplication

from gui.function.completer_highlight import *
from gui.function.python_highlight import PythonHighlighter


def string_to_html_filter(text: str) -> str:
    """
    特殊字符转换
    :param text: 原始文本
    :return: 转换后的文本
    """
    text = text.replace("&", "&amp;")
    text = text.replace(">", "&gt;")
    text = text.replace("<", "&lt;")
    text = text.replace("\"", "&quot;")
    text = text.replace("\'", "&#39;")
    text = text.replace(" ", "&nbsp;")
    text = text.replace("\n", "<br>")
    text = text.replace("\r", "<br>")
    return text


def get_img_from_text(plain_line, air_case_path, img_name):
    """
    纯文本转换为富文本图片显示
    :param plain_line: 纯文本
    :param air_case_path: 用例路径
    :param img_name: 图片名称
    :return: 富文本内容
    """
    plain_line = string_to_html_filter(plain_line)
    html_line = plain_line.replace(img_name, "<img src='" + os.path.join(air_case_path, img_name) + "'/>")
    return html_line


def get_img_list_from_text(text_list: list[str], air_case_path: str, pic_list: list[str]) -> list[tuple]:
    """
    根据文本列表获取图片列表
    :param text_list: 文本列表
    :param air_case_path: 用例地址
    :param pic_list: 图片列表
    :return: (图片路径,匹配文本行,行号)...列表
    """
    image_list = []
    for row in range(len(text_list)):
        line = text_list[row].strip("\n")
        for pic in pic_list:
            img_path = os.path.join(air_case_path, pic)
            if pic in line:
                image_list.append((img_path, line, row))
    return image_list


def search_img_in_html(html_text):
    """
    从html内容中查找img
    :param html_text: html富文本
    :return: row_list
    """
    content = html_text.split("\n")[4:]  # 跳过前四行
    row_list = [i for i in range(len(content)) if "<img src=" in content[i]]  # 返回图片所在行号
    return row_list


def get_img_from_html(html_text, row):
    """
    从html中提取图片路径,图片名
    :param html_text: html文本
    :param row: 需要处理的行号
    :return: 图片路径,图片名
    """
    html_content = html_text.split("\n")[4:]  # 跳过前四行
    html_line = html_content[row]  # 图片所在行
    img_path = html_line[
               html_line.find("<img src=") + len("<img src=") + 1:html_line.rfind('.png') + len(
                   '.png')]  # 从html行内容提取图片信息
    img_name = img_path[img_path.rfind('\\') + 1:]
    return img_path, img_name


def get_text_from_img(plain_line, html_text, row):
    """
    将图片转换为文本
    :param plain_line: 获取无法解析图片的纯文本信息
    :param html_text: html富文本
    :param row: 需要处理的行号
    :return: 处理结果
    """
    img_name = get_img_from_html(html_text, row)[1]
    value = plain_line.replace('', img_name)  # 替换图片文本内容
    return value


class LineNumPaint(QWidget):
    font_color = QColor(Qt.black)
    background_color = QColor(Qt.lightGray)

    def __init__(self, q_edit):
        super().__init__(q_edit)
        self.q_edit_line_num = q_edit

    def sizeHint(self):
        return QSize(self.q_edit_line_num.lineNumberAreaWidth(), 0)

    def paintEvent(self, event):
        self.q_edit_line_num.lineNumberAreaPaintEvent(event)


class CodeTextEdit(QTextEdit):
    """可自动补全关键字的QTextEdit"""
    copy_signal = pyqtSignal()
    paste_signal = pyqtSignal()
    show_image = False
    tab_settings = 4  # TODO:tab缩进用户自定义

    def __init__(self, parent=None):
        super(CodeTextEdit, self).__init__(parent)
        self.textChangedSign = True
        self.textChanged.connect(self.deal_text_changed)
        self.completer = QCompleter(self)
        self.search_words_dict = {}
        self.match_words_list = []
        self.autoCompleteWords_list = []  # 将传入的搜索词语匹配词整合 , 成为自动匹配选词列表
        self.specialCursorDict = {}  # 记录待选值的特殊光标位置
        self.completion_prefix = ''  # 用户已经完成的前缀
        self.air_case_file_path = ""
        self.setLineWrapMode(QTextEdit.NoWrap)
        self.completer.activated.connect(self.insert_completion)  # 为 用户更改项目文档事件 绑定 函数
        # 添加行号显示
        self.lineNumberArea = LineNumPaint(self)
        self.document().blockCountChanged.connect(self.update_line_num_width)
        self.verticalScrollBar().valueChanged.connect(self.lineNumberArea.update)
        self.textChanged.connect(self.lineNumberArea.update)
        self.cursorPositionChanged.connect(self.lineNumberArea.update)
        self.cursorPositionChanged.connect(self.update_text_cursor)
        self.update_line_num_width()
        # 设置代码编辑界面样式
        self.setStyleSheet("background-color: rgb(33, 33, 33);")
        self.setFont(QFont("Consolas", 14, 2))  # 设置字体大小
        self.lineNumberArea.setFont(QFont("Consolas", 14, 2))
        self.lineNumberArea.font_color = QColor(208, 208, 208)
        self.lineNumberArea.background_color = QColor(33, 33, 33)
        # 设置选中文本样式
        pt = QPalette()
        pt.setBrush(QPalette.Highlight, QColor(73, 72, 62))
        pt.setBrush(QPalette.HighlightedText, QBrush(Qt.NoBrush))
        self.setPalette(pt)
        # 设置文本语法高亮规则
        self.highlighter_textEdit = PythonHighlighter(self.document())

    def lineNumberAreaWidth(self):
        block_count = self.document().blockCount()
        max_value = max(1, block_count)
        d_count = len(str(max_value))
        _width = self.fontMetrics().width('9') * d_count + 5
        return _width

    def update_line_num_width(self):
        self.setViewportMargins(self.lineNumberAreaWidth(), 0, 0, 0)

    def resizeEvent(self, event):
        super().resizeEvent(event)
        cr = self.contentsRect()
        self.lineNumberArea.setGeometry(QRect(cr.left(), cr.top(), self.lineNumberAreaWidth(), cr.height()))

    def show_text_pic(self, air_case_file_path):
        """
        读取代码内容并显示文本内容、图片
        """
        self.air_case_file_path = air_case_file_path
        self.setUndoRedoEnabled(False)
        if os.path.isfile(air_case_file_path):
            air_case_path = air_case_file_path[:air_case_file_path.rfind('\\')]
            self.clear()
            pic_list = [file for file in os.listdir(air_case_path) if file.endswith(".png")]
            with open(air_case_file_path, 'r', encoding='utf-8') as wra:
                text_list = wra.readlines()
                for row in range(len(text_list)):
                    line = text_list[row].strip("\n")
                    for pic in pic_list:
                        if pic in line:
                            if self.show_image:
                                line = line.replace(pic, "<img src='" + os.path.join(air_case_path, pic) + "'/>")
                    self.append(line)  # TODO:无法在开头添加空行
            cursor = self.textCursor()
            cursor.movePosition(QTextCursor.Start)
            self.setTextCursor(cursor)
        self.setUndoRedoEnabled(True)

    def change_show_mode(self):
        """改变文本显示模式"""
        self.show_image = not self.show_image
        air_case_file_path = self.air_case_file_path
        if os.path.isfile(air_case_file_path):
            self.setUndoRedoEnabled(False)
            text_changed = self.document().isModified()  # 获取文本修改状态
            if self.show_image:  # 显示图片
                text_list = self.toPlainText().split("\n")
                air_case_path = air_case_file_path[:air_case_file_path.rfind("\\")]
                pic_list = [file for file in os.listdir(air_case_path) if file.endswith(".png")]
                image_list = get_img_list_from_text(text_list, air_case_path, pic_list)
                self.clear()
                for row in range(len(text_list)):
                    line = text_list[row]
                    for img in image_list:
                        if img[2] == row:
                            img_path = img[0]
                            img_name = img_path[img_path.rfind("\\") + 1:]
                            line = get_img_from_text(line, air_case_path, img_name)
                    self.append(line)
            else:  # 显示纯文本
                row_list = search_img_in_html(self.toHtml())
                content = ""
                for row in range(self.document().blockCount()):
                    text_line = self.document().findBlockByLineNumber(row).text()
                    if row in row_list:
                        text_line = get_text_from_img(text_line, self.toHtml(), row)
                    if content:
                        content += "\n" + text_line
                    else:
                        content += text_line
                self.clear()
                self.setText(content)
            self.setUndoRedoEnabled(True)
            if not text_changed:  # 未修改,重置文本修改状态
                self.document().setModified(False)
            else:
                self.document().setModified(True)

    def update_text_cursor(self):
        """光标改变时更新光标所在行样式"""
        if not self.document().isEmpty():
            selection = QTextEdit.ExtraSelection()
            line_color = QColor(55, 56, 49)
            selection.format.setBackground(line_color)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)
            selection.cursor = self.textCursor()
            selection.cursor.clearSelection()
            if self.textCursor().selection().isEmpty():  # 添加判断使选择文本功能正常
                self.setExtraSelections([selection])
            else:
                self.setExtraSelections([])
        else:
            self.setExtraSelections([])

    def lineNumberAreaPaintEvent(self, event):
        painter = QPainter(self.lineNumberArea)
        painter.fillRect(event.rect(), self.lineNumberArea.background_color)
        # 获取首个可见文本块
        first_visible_block_number = self.cursorForPosition(QPoint(0, 1)).blockNumber()
        # 从首个文本块开始处理
        blockNumber = first_visible_block_number
        block = self.document().findBlockByNumber(blockNumber)
        top = self.viewport().geometry().top()
        if blockNumber == 0:
            additional_margin = int(self.document().documentMargin() - 1 - self.verticalScrollBar().sliderPosition())
        else:
            prev_block = self.document().findBlockByNumber(blockNumber - 1)
            additional_margin = int(self.document().documentLayout().blockBoundingRect(
                prev_block).bottom()) - self.verticalScrollBar().sliderPosition()
        top += additional_margin
        bottom = top + int(self.document().documentLayout().blockBoundingRect(block).height())
        last_block_number = self.cursorForPosition(QPoint(0, self.height() - 1)).blockNumber()
        height = self.fontMetrics().height()
        while block.isValid() and (top <= event.rect().bottom()) and blockNumber <= last_block_number:
            if block.isVisible() and bottom >= event.rect().top():
                number = str(blockNumber + 1)
                painter.setPen(self.lineNumberArea.font_color)
                painter.drawText(0, top, self.lineNumberArea.width(), height, Qt.AlignCenter, number)
            block = block.next()
            top = bottom
            bottom = top + int(self.document().documentLayout().blockBoundingRect(block).height())
            blockNumber += 1

    def add_search_words(self, search_words_dict):
        """添加搜索字典"""
        self.search_words_dict.update(search_words_dict)

    def add_match_words(self, match_words_list):
        """添加匹配列表"""
        self.match_words_list += match_words_list

    def init_auto_complete_words(self):
        """
        处理autoCompleteWords_list,以及specialCursorDict
        """
        self.autoCompleteWords_list = []
        self.specialCursorDict = {}  # 记录待选值的特殊光标位置
        for i in self.search_words_dict:
            if "$CURSOR$" in self.search_words_dict[i]:
                cursor_position = len(self.search_words_dict[i]) - len("$CURSOR$") - self.search_words_dict[i].find(
                    "$CURSOR$")
                self.search_words_dict[i] = self.search_words_dict[i].replace("$CURSOR$", '')
                self.specialCursorDict[i] = cursor_position
        for i in self.match_words_list:
            if "$CURSOR$" in i:
                cursor_position = len(i) - len("$CURSOR$") - i.find("$CURSOR$")
                self.match_words_list[self.match_words_list.index(i)] = i.replace("$CURSOR$", '')
                self.specialCursorDict[i.replace("$CURSOR$", '')] = cursor_position
        self.autoCompleteWords_list = list(self.search_words_dict.keys()) + self.match_words_list

    def set_completer(self):
        """设置自动补全"""
        self.init_auto_complete_words()
        self.completer.setWidget(
            self)  # 设置Qcompleter 要关联的窗口小部件。在QLineEdit上设置QCompleter时,会自动调用此函数。在为自定义小部件提供Qcompleter时,需要手动调用。
        # 更改样式
        self.completer.popup().setStyleSheet("""
        QListView {
            color: #9C9C9C;
            background-color: #4F4F4F;
            font-size:18px;
        }
        QListView::item:selected //选中项
        {
            background-color: #9C9C9C;
            border: 20px solid #9C9C9C;
        }
        QListView::item:selected:active //选中并处于激活状态时
        {
            background-color: #9C9C9C;
            border: 20px solid #9C9C9C;
        }
        QListView::item {
            color: red;
            padding-top: 5px;
            padding-bottom: 5px;
        }
        QListView::item:hover {
            background-color: #9C9C9C;
        }
        QScrollBar::handle:vertical{ //滑块属性设置
            background:#4F4F4F;
            width:2px;
            height:9px;
            border: 0px;
            border-radius:100px;
            }
        QScrollBar::handle:vertical:normal{
            background-color:#4F4F4F;
            width:2px;
            height:9px;
            border: 0px;
            border-radius:100px;
            }
        QScrollBar::handle:vertical:hover{
            background:#E6E6E6;
            width:2px;
            height:9px;
            border: 0px solid #E5E5E5;
            border-radius:100px;
            }
        QScrollBar::handle:vertical:pressed{
            background:#CCCCCC;
            width:2px;
            height:9px;
            border: 0px solid #E5E5E5;
            border-radius:100px;
            }
        """)
        self.completer.setModelSorting(QCompleter.CaseSensitivelySortedModel)
        self.completer.setFilterMode(Qt.MatchContains)
        self.completer.setWrapAround(False)
        self.completer.setCompletionMode(QCompleter.PopupCompletion)
        self.completer.setModel(QStringListModel(self.autoCompleteWords_list, self.completer))

    def close_completer(self):
        """关闭自动补全选项面板"""
        self.completer.popup().hide()

    def insert_completion(self, completion):
        """
        当用户激活popup()中的项目时调用。(通过点击或按回车)
        @completion::添加 QCompleter
        """
        '''
        俩种补全方式
        1. 搜索添加 在searchWords_dict键中的对应项,删除用户输入的匹配项,自动补全其键值
        2. 匹配添加 在
        '''
        cursor = self.textCursor()
        # 判断是搜索添加 还是 直接匹配添加
        if completion in self.search_words_dict:  # 搜索添加
            # 删除输入的搜索词
            for _ in self.completer.completionPrefix():
                cursor.deletePreviousChar()
            # 让光标移到删除后的位置
            self.setTextCursor(cursor)
            # 插入对应的键值
            insert_text = self.search_words_dict[completion]
            cursor.insertText(insert_text)
            self.close_completer()
        else:  # 直接匹配添加
            # 计算用户输入和匹配项差值,并自动补全
            extra = len(completion) - len(self.completer.completionPrefix())
            # 判断用户输入单词 与 需要补全内容是否一致,一致就不用操作
            if not self.completion_prefix == completion[-extra:]:
                # 自动补全
                cursor.insertText(completion[-extra:])
                self.close_completer()
        # 判断自动补全后是否需要更改特定光标位置
        cursor.movePosition(QTextCursor.EndOfWord)  # 先移动到单词尾部,避免错误
        if completion in self.specialCursorDict.keys():  # 存在特殊位置
            for i in range(self.specialCursorDict[completion]):
                cursor.movePosition(QTextCursor.PreviousCharacter)
            self.setTextCursor(cursor)
        else:  # 不存在特殊位置,移动到单词末尾
            cursor.movePosition(QTextCursor.EndOfWord)
            self.setTextCursor(cursor)

    def focusInEvent(self, event):
        # 当edit获取焦点时激活completer
        if self.completer is not None:
            self.completer.setWidget(self)
        super(CodeTextEdit, self).focusInEvent(event)

    def deal_clipboard_content(self):  # TODO:检查代码兼容性
        """处理剪切板内容"""
        clipboard = QApplication.clipboard()
        if not self.show_image:  # 纯文本
            row_list = search_img_in_html(clipboard.mimeData().html())
            if row_list:  # 存在图片的情况下,图片转纯文本
                text_list = [get_text_from_img(clipboard.text(), clipboard.mimeData().html(), row) + "\n" for
                             row in row_list]
                text = "".join(text_list).strip("\n")
                self.insertPlainText(text)
            else:  # 不存在图片,不做处理
                self.paste()
        else:  # 支持图片显示
            air_case_file_path = self.air_case_file_path
            copy_content = clipboard.text()
            target_path = air_case_file_path[:air_case_file_path.rfind("\\")]
            string_list1 = [result.strip('"') for result in re.split(r'("[^"]*")', copy_content) if
                            result and result[0] == '"' and result.endswith('.png"')]
            string_list2 = [result.strip("'") for result in re.split(r"('[^']*')", copy_content) if
                            result and result[0] == "'" and result.endswith(".png'")]
            string_list = string_list1 + string_list2
            if string_list:  # 代码里有可转换显示的图片资源
                for word in string_list:
                    copy_content = copy_content.replace(word,
                                                        "<img src='" + os.path.join(target_path,
                                                                                    word) + "'/>")
                copy_content = copy_content.replace("\n", "<br>")
                copy_content = copy_content.replace(" " * self.tab_settings, "&nbsp;" * self.tab_settings)
                self.insertHtml(copy_content)
            else:  # 无可转换显示的图片资源
                self.paste()

    def deal_text_changed(self):
        """
        内容改变处理信号处理
        """
        '''下面是对keyPressEvent面对中文输入法输入时没反应的补充'''
        connect = self.toPlainText()

        class QKeyEvent:
            def key(self=None):
                return 0

            def text(self=None):
                return connect.split('\n')[-1].split(' ')[-1]

            def modifiers(self=None):
                return Qt.AltModifier

        self.keyPressEvent(type('QKeyEvent', (QKeyEvent,), {}))

    def get_last_phrase(self):
        """
        获取光标所在行左侧最后一个词组
        """
        start = self.textCursor().block().position()
        end = self.textCursor().position()
        cursor = self.textCursor()
        cursor.setPosition(start, QTextCursor.MoveAnchor)
        cursor.setPosition(end, QTextCursor.KeepAnchor)
        connect = cursor.selectedText()  # 获取光标所在行左侧文本
        last_phrase = connect.split(' ')[-1]  # 按空格分割获取最后一个词组
        return last_phrase

    def keyPressEvent(self, event):
        """
        按键按下事件
        """
        is_shortcut = False  # 判断是否是快捷键的标志
        if self.completer is not None and self.completer.popup().isVisible():
            # 如果键入的是特殊键则忽略这次事件
            if event.key() in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Escape, Qt.Key_Tab, Qt.Key_Backtab):
                event.ignore()
                return
        # Ctrl + e 快捷键
        if event.key() == Qt.Key_E and event.modifiers() == Qt.ControlModifier:
            words = self.autoCompleteWords_list
            self.completer.setModel(QStringListModel(words))  # 设置数据
            is_shortcut = True
        # 当不存在关联的 completer 以及 当前键不是快捷键的时候执行父操作
        if (self.completer is None or not is_shortcut) and event.key() != 0:
            # Do not process the shortcut when we have a completer.
            if event.key() == Qt.Key_Tab:
                if self.textCursor().selection().isEmpty():
                    self.insertPlainText(" " * self.tab_settings)
                else:
                    self.textCursor().beginEditBlock()
                    cursor = self.textCursor()
                    loc = cursor.position()
                    start_origin = self.textCursor().selectionStart()
                    end_origin = self.textCursor().selectionEnd()
                    cursor.setPosition(self.document().findBlock(start_origin).position(), QTextCursor.MoveAnchor)
                    self.setTextCursor(cursor)
                    cursor.setPosition(end_origin, QTextCursor.KeepAnchor)
                    self.setTextCursor(cursor)
                    start = self.textCursor().selectionStart()
                    end = self.textCursor().selectionEnd()
                    text_list = self.textCursor().selection().toPlainText().split("\n")
                    cursor = self.textCursor()
                    cursor.clearSelection()
                    self.setTextCursor(cursor)
                    cursor.setPosition(start)
                    self.setTextCursor(cursor)
                    self.insertPlainText(" " * self.tab_settings)
                    for _ in range(len(text_list) - 1):
                        cursor.movePosition(QTextCursor.Down)
                        self.setTextCursor(cursor)
                        self.insertPlainText(" " * self.tab_settings)
                    start = start_origin + self.tab_settings
                    end = end + len(text_list) * self.tab_settings
                    if loc == end_origin:
                        cursor.setPosition(start)
                        cursor.setPosition(end, QTextCursor.KeepAnchor)
                    else:
                        cursor.setPosition(end)
                        cursor.setPosition(start, QTextCursor.KeepAnchor)
                    self.setTextCursor(cursor)
                    self.textCursor().endEditBlock()
            elif event.key() == Qt.Key_Backtab:
                def delete_tab():
                    if self.textCursor().block().text().startswith(" " * self.tab_settings):
                        self.setTextCursor(QTextCursor(self.textCursor().block()))
                        for i in range(self.tab_settings):
                            self.textCursor().deleteChar()
                        return True
                    else:
                        return False

                cursor = self.textCursor()
                loc = cursor.position()
                if cursor.selection().isEmpty():
                    if delete_tab():
                        cursor.setPosition(loc - self.tab_settings)
                        self.setTextCursor(cursor)
                else:
                    self.textCursor().beginEditBlock()
                    start_origin = cursor.selectionStart()
                    end_origin = cursor.selectionEnd()
                    line_start = self.document().findBlock(start_origin).position()
                    start = start_origin
                    text_list = cursor.selection().toPlainText().split("\n")
                    cursor.clearSelection()
                    self.setTextCursor(cursor)
                    cursor.setPosition(start)
                    self.setTextCursor(cursor)
                    start_del = delete_tab()
                    num = 1 if start_del else 0
                    end_del = start_del
                    for _ in range(len(text_list) - 1):
                        cursor.movePosition(QTextCursor.Down)
                        self.setTextCursor(cursor)
                        end_del = delete_tab()
                        if end_del:
                            num += 1
                    start = start_origin - self.tab_settings if start_del and start_origin != line_start else start_origin
                    end = end_origin - num * self.tab_settings if end_del else end_origin
                    if loc == end_origin:
                        cursor.setPosition(start)
                        cursor.setPosition(end, QTextCursor.KeepAnchor)
                    else:
                        cursor.setPosition(end)
                        cursor.setPosition(start, QTextCursor.KeepAnchor)
                    self.setTextCursor(cursor)
                    self.textCursor().endEditBlock()
            elif event.key() == Qt.Key_C and event.modifiers() == Qt.ControlModifier:
                self.copy_signal.emit()
                super(CodeTextEdit, self).keyPressEvent(event)
            elif event.key() == Qt.Key_V and event.modifiers() == Qt.ControlModifier:
                self.paste_signal.emit()
                self.deal_clipboard_content()
            elif event.key() == Qt.Key_Slash and event.modifiers() == Qt.ControlModifier:
                def comment_setting():
                    code_line = self.textCursor().block().text()
                    index = QRegExp("[^\s]").indexIn(code_line)  # 正则匹配非空字符
                    cursor = self.textCursor()
                    loc = cursor.position()
                    if index != -1:
                        if code_line[index] != "#":
                            cursor.setPosition(index + cursor.block().position())
                            self.setTextCursor(cursor)
                            self.insertPlainText("# ")
                            return loc + 2
                        else:
                            cursor.setPosition(index + cursor.block().position())
                            self.setTextCursor(cursor)
                            self.textCursor().deleteChar()
                            if len(code_line) > index + 1 and code_line[index + 1] == " ":
                                self.textCursor().deleteChar()
                                return loc - 2
                            else:
                                return loc - 1
                    else:
                        self.moveCursor(QTextCursor.StartOfLine)
                        self.insertPlainText("# ")
                        return loc + 2

                self.textCursor().beginEditBlock()
                cursor = self.textCursor()
                if cursor.selection().isEmpty():
                    loc_new = comment_setting()
                    cursor.setPosition(loc_new)
                    self.setTextCursor(cursor)
                else:
                    loc = cursor.position()
                    start_origin = cursor.selectionStart()
                    end_origin = cursor.selectionEnd()
                    start = start_origin
                    text_list = cursor.selection().toPlainText().split("\n")
                    cursor.clearSelection()
                    self.setTextCursor(cursor)
                    cursor.setPosition(start)
                    self.setTextCursor(cursor)
                    start = comment_setting()
                    end = start - start_origin + end_origin
                    for _ in range(len(text_list) - 1):
                        cursor.movePosition(QTextCursor.Down)
                        self.setTextCursor(cursor)
                        self.moveCursor(QTextCursor.EndOfLine)
                        end = comment_setting()
                    if loc == end_origin:
                        cursor.setPosition(start)
                        cursor.setPosition(end, QTextCursor.KeepAnchor)
                    else:
                        cursor.setPosition(end)
                        cursor.setPosition(start, QTextCursor.KeepAnchor)
                    self.setTextCursor(cursor)
                self.textCursor().endEditBlock()
            else:
                super(CodeTextEdit, self).keyPressEvent(event)
        # 当当不存在关联的 completer 或者 有修饰符(ctrl或shift)和输入字符为空时 , 直接返回不进行任何操作
        ctrl_or_shift = event.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier)  # 是ctrl或shift这样的修饰词
        if self.completer is None or (ctrl_or_shift and len(event.text()) == 0):
            return
        last_phrase = self.get_last_phrase()  # 当前出现的单词
        self.completion_prefix = last_phrase
        # 限制最少输入2个字符后才进行匹配 , 且不满足条件自动关闭界面
        # 不是快捷键,同时满足(没有修饰符 或者 文本为空 或者 输入字符少于2 或者 输入文本最后以eow中一个字符结尾)
        if not is_shortcut and (len(event.text()) == 0 or len(last_phrase) < 2):
            self.close_completer()
            return
        # 选中第一项
        if last_phrase != self.completer.completionPrefix():
            # Puts the Prefix of the word you're typing into the Prefix
            self.completer.setCompletionPrefix(last_phrase)
            self.completer.popup().setCurrentIndex(self.completer.completionModel().index(0, 0))
        cursor_rect = self.cursorRect()
        # 设置 completer 尺寸
        cursor_rect.setWidth(self.completer.popup().sizeHintForColumn(
            0) + self.completer.popup().verticalScrollBar().sizeHint().width())
        self.completer.complete(cursor_rect)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    codeEditor = CodeTextEdit()
    codeEditor.setWindowTitle("红豆泥明月的富文本代码编辑框")
    codeEditor.setGeometry(100, 100, 800, 600)
    codeEditor.show_image = True
    codeEditor.show_text_pic(r"E:\FileforPython\Airtest_Runner\air\测试用例二.air\测试用例二.py")
    FUNCTIONS_RESORTED = []
    for function in [(name, _) for (name, _) in inspect.getmembers(common.Application, inspect.isfunction)]:
        params = inspect.signature(function[1]).parameters
        for name, param in params.items():
            if param.default == inspect._empty:
                FUNCTIONS_RESORTED.append(function[0] + "($CURSOR$)")
                break
        else:
            FUNCTIONS_RESORTED.append(function[0] + "()")
    BUILTINS_builtins_RESORTED = [builtin + "($CURSOR$)" for builtin in BUILTINS_builtins]
    match_words = KEYWORDS + BUILTINS_classes + BUILTINS_builtins_RESORTED + CONSTANTS + CLASSES + FUNCTIONS_RESORTED
    codeEditor.add_match_words(match_words)
    codeEditor.add_search_words(FUNCTIONS_DICT)
    codeEditor.set_completer()
    codeEditor.show()
    sys.exit(app.exec_())

结果展示

纯代码模式:
纯代码模式

图片模式:
图片模式

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值