PyQt5 突出显示选定的 TreeWidget 单元格

PyQt5 Highlighting a selected TreeWidget cell

简而言之,我的树 table 使用浮点数,我想限制 table 中显示的小数位数,但我不想丢失数据,因为我用它进行计算。我对 ItemDelegate 进行了子类化并覆盖了 paint 方法,这样我就可以在单元格中绘制更少的小数点而不会实际丢失数据。代码如下,重点放在 try 块的最后一行。

def paint(self, painter, option, index):
    value = index.model().data(index, QtCore.Qt.EditRole)
    item = self.treeWidget.itemFromIndex(index)
    col = index.column()

    try:
        if col == 0:
            QtWidgets.QItemDelegate.paint(self, painter, option, index)

        else:

            if isinstance(item, AssignmentType):
                painter.setPen(QtCore.Qt.darkGreen)
                painter.setFont(typeFont)

            elif isinstance(item, Assignment):
                painter.setPen(QtCore.Qt.blue)
                painter.setFont(assFont)
            else:
                painter.setPen(QtCore.Qt.darkBlue)
                painter.setFont(courseFont)

            text = value if "/" in value else "{:.{}f}".format(float(value)*100, self.nDecimals)
            painter.drawText(option.rect, QtCore.Qt.AlignCenter, text)

    except:
        QtWidgets.QItemDelegate.paint(self, painter, option, index)

这样做的一个副作用是受 painter.drawText(option.rect, QtCore.Qt.AlignCenter, text) 代码行影响的单元格在单击时不会突出显示。我已经尝试在我的绘画方法中有条件地更改树小部件的调色板,但这似乎没有帮助。我认为连接 itemClicked 信号会有所帮助,但我不知道我会在那里做什么。 python 或 c 中的任何 ideas/code 将不胜感激。该项目的完整代码和示例 .grdb 文件如下:

import json

from PyQt5 import QtCore, QtGui, QtWidgets

courseFont = QtGui.QFont()
courseFont.setBold(True)
courseFont.setWeight(100)
courseFont.setPointSize(18)

typeFont = QtGui.QFont()
typeFont.setUnderline(True)
typeFont.setPointSize(16)
typeFont.setWeight(50)

assFont = QtGui.QFont()
assFont.setItalic(True)
assFont.setPointSize(14)
assFont.setWeight(50)

extraCreditFont = QtGui.QFont()
extraCreditFont.setItalic(True)
extraCreditFont.setUnderline(True)
extraCreditFont.setPointSize(14)
extraCreditFont.setWeight(75)


class KeyPressedTree(QtWidgets.QTreeWidget):
    keyPressed = QtCore.pyqtSignal(int)

    def keyPressEvent(self, event):
        super(KeyPressedTree, self).keyPressEvent(event)
        self.keyPressed.emit(event.key())


class Course(QtWidgets.QTreeWidgetItem):
    def __init__(self, parent, data=["New Course", "", ""], *__args):
        super().__init__(parent, data)
        self.setFont(0, courseFont)
        self.setFlags(
            QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled)


class AssignmentType(QtWidgets.QTreeWidgetItem):
    def __init__(self, parent, data=["New Assignment Type", "", ""], *__args):
        super().__init__(parent, data)
        self.setFont(0, typeFont)
        self.setFlags(
            QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled)

        self.extraCredit = False

    def setExtraCredit(self,isExtra):
        self.extraCredit = isExtra
    def isExtraCredit(self):
        return self.extraCredit


class Assignment(QtWidgets.QTreeWidgetItem):
    def __init__(self, parent, data=["New Assignment", "", ""], *__args):
        super().__init__(parent, data)
        self.setFont(0, assFont)
        self.extraCredit = False
        self.setFlags(
            QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled)

    def setExtraCredit(self,isExtra):
        self.extraCredit = isExtra
    def isExtraCredit(self):
        return self.extraCredit



class ValidWeightGradeInput(QtWidgets.QItemDelegate):
    def createEditor(self, parent, option, index):
        line = QtWidgets.QLineEdit(parent)
        reg_ex = QtCore.QRegExp(r"|0?\.\d+|\d*\.?\d+/\d*\.?\d+")
        input_validator = QtGui.QRegExpValidator(reg_ex, line)
        line.setValidator(input_validator)
        return line


class FloatDelegate(QtWidgets.QItemDelegate):
    def __init__(self, decimals, parent: KeyPressedTree):
        self.treeWidget = parent
        QtWidgets.QItemDelegate.__init__(self, parent=parent)
        self.nDecimals = decimals

    def createEditor(self, parent, option, index):
        # if any of the below conditions are met, then the cell is editable.
        # basically, if the first index is zero, then it's editable
        # if the weight column for the assignment type item is selected, then it's editable
        # if the grade column for the assignment item is selected, it's editable
        # in all other cases, it's not editable.
        if index.column() == 0:
            return QtWidgets.QItemDelegate.createEditor(self, parent, option, index)
        elif (
                index.column() == 1 and isinstance(self.treeWidget.itemFromIndex(index), AssignmentType)) or (
                index.column() == 2 and isinstance(self.treeWidget.itemFromIndex(index), Assignment)):

            return ValidWeightGradeInput.createEditor(self, parent, option, index)


        else:
            return None

    def paint(self, painter, option, index):
        value = index.model().data(index, QtCore.Qt.EditRole)
        item = self.treeWidget.itemFromIndex(index)
        col = index.column()

        try:
            if col == 0:
                QtWidgets.QItemDelegate.paint(self, painter, option, index)

            else:

                if isinstance(item, AssignmentType):
                    painter.setPen(QtCore.Qt.darkGreen)
                    painter.setFont(typeFont)

                elif isinstance(item, Assignment):
                    painter.setPen(QtCore.Qt.blue)
                    painter.setFont(assFont)
                else:
                    painter.setPen(QtCore.Qt.darkBlue)
                    painter.setFont(courseFont)

                text = value if "/" in value else "{:.{}f}".format(float(value)*100, self.nDecimals)
                painter.drawText(option.rect, QtCore.Qt.AlignCenter, text)

        except:
            QtWidgets.QItemDelegate.paint(self, painter, option, index)


class Ui_MainWindow(QtWidgets.QMainWindow):
    def setupUi(self):
        self.setWindowTitle("Grade Manager")
        self.resize(620, 600)
        self.centralwidget = QtWidgets.QWidget(self)
        self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
        self.treeWidget = KeyPressedTree(self.centralwidget)
        sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
        sizePolicy.setHeightForWidth(self.treeWidget.sizePolicy().hasHeightForWidth())
        self.treeWidget.setSizePolicy(sizePolicy)
        self.treeWidget.setMinimumSize(QtCore.QSize(620, 600))

        font = QtGui.QFont()
        font.setPointSize(20)
        font.setWeight(100)
        self.treeWidget.setFont(font)

        self.treeWidget.setAlternatingRowColors(True)
        self.treeWidget.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems)
        self.treeWidget.setAnimated(True)
        self.treeWidget.setWordWrap(True)

        self.treeWidget.headerItem().setText(0, "Course")
        self.treeWidget.headerItem().setText(1, "Weight")
        self.treeWidget.headerItem().setText(2, "Grade")
        self.treeWidget.headerItem().setTextAlignment(0, QtCore.Qt.AlignCenter)
        self.treeWidget.headerItem().setTextAlignment(1, QtCore.Qt.AlignCenter)
        self.treeWidget.headerItem().setTextAlignment(2, QtCore.Qt.AlignCenter)


        self.treeWidget.setColumnWidth(0,370)
        self.treeWidget.setColumnWidth(1,130)
        self.treeWidget.setColumnWidth(2,100)
        # self.treeWidget.header().setDefaultSectionSize(275)
        # self.treeWidget.header().setMinimumSectionSize(50)
        self.treeWidget.header().setStretchLastSection(True)

        self.verticalLayout.addWidget(self.treeWidget)
        self.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(self)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 912, 35))

        self.menuFile = QtWidgets.QMenu(self.menubar)
        self.menuFile.setTitle("Fi&le")

        self.setMenuBar(self.menubar)
        self.actionOpen = QtWidgets.QAction(self, text="&Open")
        self.actionOpen.setShortcut("Ctrl+O")
        self.actionClose = QtWidgets.QAction(self, text="&Close")
        self.actionNew = QtWidgets.QAction(self, text="&New")
        self.actionSave = QtWidgets.QAction(self, text="&Save")
        self.actionSave.setShortcut("Ctrl+S")
        self.actionSave_as = QtWidgets.QAction(self, text="Sa&ve as...")
        self.actionSave_as.setShortcut("Ctrl+Shift+S")

        self.menuFile.addAction(self.actionNew)
        self.menuFile.addAction(self.actionOpen)
        self.menuFile.addAction(self.actionClose)
        self.menuFile.addSeparator()
        self.menuFile.addAction(self.actionSave)
        self.menuFile.addAction(self.actionSave_as)
        self.menubar.addAction(self.menuFile.menuAction())

        self.courses = []
        self.treeWidget.setItemDelegate(FloatDelegate(2, self.treeWidget))

        self.treeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)

        self.treeWidget.customContextMenuRequested.connect(self.openMenu)

        self.actionSave.triggered.connect(self.saveJSON)
        self.actionOpen.triggered.connect(self.readJSON)
        self.actionSave_as.triggered.connect(self.saveAsJSON)
        self.actionNew.triggered.connect(self.clearPage)
        self.actionClose.triggered.connect(self.close)

        self.treeWidget.itemChanged.connect(self.itemClicked)
        self.treeWidget.keyPressed.connect(self.keyPressed)

        self.filename = None
        self.change_made = False

    def clearPage(self):
        if self.change_made:
            answer = QtWidgets.QMessageBox.question(self, "Close Confirmation",
                                                    "Would you like to save before exiting?",
                                                    QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Cancel)

            if answer == QtWidgets.QMessageBox.Cancel:
                return
            elif answer == QtWidgets.QMessageBox.Yes:
                self.saveJSON()
        self.treeWidget.clear()
        self.courses = []
        self.filename = None
        self.change_made = False

    def addCourse(self):
        course = Course(self.treeWidget)
        course.setExpanded(True)
        self.courses.append(course)
        self.change_made = True

    def addType(self, course):
        t = AssignmentType(course)
        t.setExpanded(True)
        course.addChild(t)
        self.change_made = True

    def addAssignment(self, assignment_type):
        ass = Assignment(assignment_type)
        assignment_type.addChild(ass)
        self.change_made = True

    def removeItem(self, item, level):
        root = self.treeWidget.invisibleRootItem()
        parent = item.parent()
        (parent or root).removeChild(item)

        if level == 2:  # if just the assignment was removed
            self.updateTypeGrade(parent)
        elif level == 1:  # if the assignment type was just removed
            self.updateCourseGrade(parent)
        self.change_made = True

    def openMenu(self, position):
        menu = QtWidgets.QMenu(self)
        indices = self.treeWidget.selectedItems()

        level = 0
        if not indices:
            menu.addAction(self.tr("Add New Course"))

        else:
            i = indices[0]
            while i.parent():
                i = i.parent()
                level += 1
            choices = (("Add New Course", "Add New Assignment Type", "Remove Selected Course"),
                       ("Add New Assignment", "Remove Selected Assignment Type"), ("Remove Assignment",
                                                                                   "Set As Not Extra Credit" if level == 2 and indices[0].isExtraCredit() else "Set As Extra Credit"))

            [menu.addAction(self.tr(act)) for act in choices[level]]

        action = menu.exec_(self.treeWidget.viewport().mapToGlobal(position))
        if action:
            action = action.text()
            if action == "Add New Course":
                self.addCourse()
            elif action == "Add New Assignment Type":
                self.addType(indices[0])
            elif action == "Add New Assignment":
                self.addAssignment(indices[0])
            elif action == "Set As Extra Credit":
                indices[0].setExtraCredit(True)
                self.updateTypeGrade(indices[0].parent())
                indices[0].setFont(0,extraCreditFont)
                self.change_made = True
            elif action == "Set As Not Extra Credit":
                indices[0].setExtraCredit(False)
                self.updateTypeGrade(indices[0].parent())
                indices[0].setFont(0,assFont)
                self.change_made = True
            else:
                self.removeItem(indices[0], level)

    def saveJSON(self):
        self.filename = self.save(self.filename)

    def saveAsJSON(self):
        self.save()

    def transformInput(self, data: str):
        if "/" in data:
            i = data.find("/")
            return float(data[:i]) / float(data[i + 1:])
        return float(data)

    def updateTypeGrade(self, ass_type):
        type_grade = 0.0
        num_assignments = ass_type.childCount()
        for i in range(num_assignments):
            grade = ass_type.child(i).text(2)
            if not grade:  # if the column is empty
                num_assignments -= 1
                continue
            if ass_type.child(i).isExtraCredit():
                num_assignments -= 1
            type_grade += self.transformInput(grade)
        type_grade = f"{type_grade / num_assignments}" if num_assignments > 0 else ""
        ass_type.setText(2, type_grade)

    def updateCourseGrade(self, course):
        total_weight = 0.0
        earned_weight = 0.0
        for i in range(course.childCount()):
            t = course.child(i)
            weight = t.text(1)
            grade = t.text(2)
            if not weight or not grade:  # if no weight is entered
                continue
            total_weight += self.transformInput(weight)
            earned_weight += self.transformInput(weight) * self.transformInput(grade)
        course_grade = str(earned_weight / total_weight) if total_weight > 0 else ""
        course.setText(2, course_grade)

    def itemClicked(self, item, col):
        self.change_made = True
        if isinstance(item, Course) or col == 0:
            return
        elif isinstance(item, Assignment):
            item = item.parent()  # changes assignment to the assignment type
            self.updateTypeGrade(item)
        # from this point, the item must be an assignment type.
        self.updateCourseGrade(item.parent())

    def keyPressed(self, key):
        indices = self.treeWidget.selectedItems()

        level = 0
        if not indices:  # if no tree item is selected
            if key == QtCore.Qt.Key_Insert:
                self.addCourse()
        else:
            i = indices[0]
            while i.parent():
                i = i.parent()
                level += 1
            i = indices[0]
            if key == QtCore.Qt.Key_Delete:
                self.removeItem(i, level)

            else:
                if level == 0:
                    if key == QtCore.Qt.Key_Insert:
                        self.addType(i)
                elif level == 1:
                    if key == QtCore.Qt.Key_Insert:
                        self.addAssignment(i)
                elif level == 2:
                    pass

    def save(self, filename=None):
        if not filename:  # if a file hasn't been opened yet (save as or new file)
            filename, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save File", "./",
                                                                "Gradebook Files (*.grdb)")
        if filename:
            data = {"Course": []}
            for course in self.courses:
                c_data = {"Name": course.text(0), "Weight": course.text(1), "Grade": course.text(2),
                          "Expanded": course.isExpanded(), "Types": []}
                for i in range(course.childCount()):
                    t = course.child(i)
                    t_data = {"Name": t.text(0), "Weight": t.text(1), "Grade": t.text(2), "Expanded": t.isExpanded(),
                              "Assignments": []}
                    for j in range(t.childCount()):
                        ass = t.child(j)
                        t_data["Assignments"].append({"Name": ass.text(0), "Weight": ass.text(1), "Grade": ass.text(2), "Extra Credit": ass.isExtraCredit()})
                    c_data["Types"].append(t_data)
                data["Course"].append(c_data)

            with open(filename.replace(".grdb", "") + ".grdb", "w+") as f:
                json.dump(data, f)
            self.change_made = True
        return filename

    def readJSON(self):
        filename, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select File", "",
                                                            "Gradebook Files (*.grdb)")
        if filename:
            with open(filename) as json_file:
                self.clearPage()
                self.filename = filename
                data = json.load(json_file)

                for course_dict in data["Course"]:
                    course = Course(self.treeWidget, [course_dict["Name"], course_dict["Weight"], course_dict["Grade"]])
                    course.setExpanded(course_dict["Expanded"])
                    for type_dict in course_dict["Types"]:
                        t = AssignmentType(course, [type_dict["Name"], type_dict["Weight"], type_dict["Grade"]])
                        t.setExpanded(type_dict["Expanded"])
                        for assignment in type_dict["Assignments"]:
                            ass = Assignment(t, [assignment["Name"], assignment["Weight"], assignment["Grade"]])
                            if assignment["Extra Credit"]:
                                ass.setExtraCredit(True)
                                ass.setFont(0,extraCreditFont)
                            t.addChild(ass)
                        course.addChild(t)
                    self.courses.append(course)
            self.change_made = False

    def closeEvent(self, event):
        if self.change_made:
            event.ignore()
            answer = QtWidgets.QMessageBox.question(self, "Close Confirmation",
                                                    "Would you like to save before exiting?",
                                                    QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Cancel)

            if answer == QtWidgets.QMessageBox.Cancel:
                return
            elif answer == QtWidgets.QMessageBox.Yes:
                self.saveJSON()

            event.accept()


if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)
    ui = Ui_MainWindow()
    ui.setupUi()
    ui.show()
    sys.exit(app.exec_())

{"Course": [{"Name": "A Course", "Weight": "", "Grade": "0.9099999999999999", "Expanded": true, "Types": [{"Name": "Exams", "Weight": ".7", "Grade": "0.925", "Expanded": true, "Assignments": [{"Name": "Exam 1", "Weight": "", "Grade": "95/100", "Extra Credit": false}, {"Name": "Exam 2", "Weight": "", "Grade": ".9", "Extra Credit": false}]}, {"Name": "Quizzes", "Weight": "3/10", "Grade": "0.875", "Expanded": true, "Assignments": [{"Name": "Quiz 1", "Weight": "", "Grade": "8/10", "Extra Credit": false}, {"Name": "Quiz 2", "Weight": "", "Grade": ".95", "Extra Credit": false}, {"Name": "Quiz 3", "Weight": "", "Grade": "", "Extra Credit": false}]}]}]}

不要直接用QPainter画图,只修改QStyleOptionViewItem,文字变化部分要覆盖drawDisplay方法。

class FloatDelegate(QtWidgets.QItemDelegate):
    def __init__(self, decimals, parent: KeyPressedTree):
        self.treeWidget = parent
        Qsuper(FloatDelegate, self).__init__(parent=parent)
        self.nDecimals = decimals

    def createEditor(self, parent, option, index):
        # ...

    def paint(self, painter, option, index):
        item = self.treeWidget.itemFromIndex(index)            
        if index.column() != 0:
            font = courseFont
            color = QtCore.Qt.darkBlue
            if isinstance(item, AssignmentType):
                color = QtCore.Qt.darkGreen
                font = typeFont
            elif isinstance(item, Assignment):
                color = QtCore.Qt.blue
                font = assFont
            cg = QtGui.QPalette.Normal if option.state & QtWidgets.QStyle.State_Enabled else QtGui.QPalette.Disabled
            option.palette.setColor(cg, QtGui.QPalette.Text, color)
            option.font = font
        super(FloatDelegate, self).paint(painter, option, index)

    def drawDisplay(self, painter, option, rect, text):
        if "/" not in text:
            try:
                text =  "{:.{}f}".format(float(text)*100, self.nDecimals)
            except ValueError:
                pass
        super(FloatDelegate, self).drawDisplay(painter, option, rect, text)