pythonpython-3.xpyqtpyqt5qitemdelegate

PyQt5 Highlighting a selected TreeWidget cell


In short, my tree table works with floating points and I wanted to restrict the number of decimal places shown in the table, but I didn't want to lose the data because I perform calculations with it. I subclassed ItemDelegate and overrode the paint method so that I could draw fewer decimal points in the cell without actually losing the data. The code for this is below, the main focus being on the last line of the try block.

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)

A side effect of this is that cells affected by the painter.drawText(option.rect, QtCore.Qt.AlignCenter, text) line of code are not highlighted when they are clicked. I've tried changing the palette of the tree widget conditionally within my paint method but that doesn't seem to help. I thought that connecting the itemClicked signal would help but I don't know what I would do in there. Any ideas/code in python or c would be appreciated. The full code and an example .grdb file for the project is below:

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}]}]}]}

Solution

  • Instead of doing a direct painting with QPainter, only the QStyleOptionViewItem should be modified, and the part of the text change should be done by overwriting the drawDisplay method.

    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)