1

I have created subclass of QTreeWidget and QTreeWidgetItem where I am trying to highlight the new added items (where the text will be colored red).

The tree hierarchy that I have adopted is as follows:

|-- parent
|--|-- child
|--|-- child

The tree widget is initially populated from a dictionary.

To get the difference, I did it by converting the current hierarchy in the tree widget into a dictionary and have it compared against the initial dictionary that it was populated with.

However if I add in a new child to an existing parent in which the name of the new child already existed in another parent, the same method as mentioned above does not works, as it will colored the first result that it find.

To replicate:

  • select menuB
  • right mouse click > add new sub item
  • input name: a101
  • hit "Highlight Diff." button
  • a101 child item under menuA is highlighted instead of the one in menuB

What would be the best way to go in getting the index of newly added child(ren)?

Thank you for any replies.

P.S: If anyone has better suggestion for the parent highlighting, please feel free to chip in.

class CustomTreeWidgetItem(QtGui.QTreeWidgetItem):
    def __init__(self, widget=None, text=None, is_tristate=False):
        super(CustomTreeWidgetItem, self).__init__(widget)

        self.setText(0, text)

        if is_tristate:
            # Solely for the Parent item
            self.setFlags(
                self.flags()
                | QtCore.Qt.ItemIsTristate
                | QtCore.Qt.ItemIsEditable
                | QtCore.Qt.ItemIsUserCheckable
            )
        else:
            self.setFlags(
                self.flags()
                | QtCore.Qt.ItemIsEditable
                | QtCore.Qt.ItemIsUserCheckable
            )
            self.setCheckState(0, QtCore.Qt.Unchecked)

    def setData(self, column, role, value):
        state = self.checkState(column)
        QtGui.QTreeWidgetItem.setData(self, column, role, value)
        if (role == QtCore.Qt.CheckStateRole and
            state != self.checkState(column)):
            tree_widget = CustomTreeWidget()
            if tree_widget is not None:
                tree_widget.itemToggled.emit(self, column)



class CustomTreeWidget(QtGui.QTreeWidget):
    def __init__(self, widget=None):
        super(CustomTreeWidget, self).__init__(widget)
        self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.show_custom_menu)


    def show_custom_menu(self):
        base_node = self.selectedItems()[0]

        qmenu = QtGui.QMenu(self)
        remove_action = QtGui.QAction("Remove item", self)
        remove_action.triggered.connect(self.remove_selected_item)
        qmenu.addAction(remove_action)

        # The following options are only effected for top-level items
        # top-level items do not have `parent()`
        if not base_node.parent():
            add_new_child_action = QtGui.QAction("Add new sub item", self)
            add_new_child_action.triggered.connect(
                partial(self.adds_new_child_item, base_node)
            )
            # qmenu.addAction(add_new_child_action)
            qmenu.insertAction(remove_action, add_new_child_action)

        qmenu.exec_(QtGui.QCursor.pos())

    def add_item_dialog(self, title):
        text, ok = QtGui.QInputDialog.getText(
            self,
            "Add {0} Item".format(title),
            "Enter name for {0}-Item:".format(title)
        )
        if ok and text != "":
            return text

    def add_new_parent_item(self):
        input_text = self.add_item_dialog("Parent")
        if input_text:
            CustomTreeWidgetItem(self, input_text, is_tristate=True)

    def adds_new_child_item(self, base_node):

        input_text = self.add_item_dialog("Sub")
        if input_text:
            CustomTreeWidgetItem(base_node, input_text)
            self.setItemExpanded(base_node, True)

    def remove_selected_item(self):
        root = self.invisibleRootItem()
        for item in self.selectedItems():
            (item.parent() or root).removeChild(item)

    def derive_tree_items(self, mode="all"):
        all_items = defaultdict(list)

        root_item = self.invisibleRootItem()
        top_level_count = root_item.childCount()

        for i in range(top_level_count):
            top_level_item = root_item.child(i)
            top_level_item_name = str(top_level_item.text(0))
            child_num = top_level_item.childCount()

            all_items[top_level_item_name] = []

            for n in range(child_num):
                child_item = top_level_item.child(n)
                child_item_name = str(child_item.text(0)) or ""

                if mode == "all":
                    all_items[top_level_item_name].append(child_item_name)

                elif mode == "checked":
                    if child_item.checkState(0) == QtCore.Qt.Checked:
                        all_items[top_level_item_name].append(child_item_name)

                elif mode == "unchecked":
                    if child_item.checkState(0) == QtCore.Qt.Unchecked:
                        all_items[top_level_item_name].append(child_item_name)

        return all_items


class MainApp(QtGui.QWidget):
    def __init__(self):
        super(MainApp, self).__init__()

        # initial dictionary that is populated into the tree widget
        test_dict = {
            "menuA": ["a101", "a102"],
            "menuC": ["c101", "c102", "c103"],
            "menuB": ["b101"],
        }

        self._tree = CustomTreeWidget()
        self._tree.header().hide()

        for pk, pv in sorted(test_dict.items()):
            parent = CustomTreeWidgetItem(self._tree, pk, is_tristate=True)

            for c in pv:
                child = CustomTreeWidgetItem(parent, c)

        self.orig_dict = self._tree.derive_tree_items()

        # Expand the hierarchy by default
        self._tree.expandAll()

        tree_layout = QtGui.QVBoxLayout()
        self.btn1 = QtGui.QPushButton("Add new item")
        self.btn2 = QtGui.QPushButton("Highlight Diff.")
        tree_layout.addWidget(self._tree)
        tree_layout.addWidget(self.btn1)
        tree_layout.addWidget(self.btn2)

        main_layout = QtGui.QHBoxLayout()
        main_layout.addLayout(tree_layout)

        self.setLayout(main_layout)
        self.setup_connections()

    def setup_connections(self):
        self.btn1.clicked.connect(self.add_parent_item)
        self.btn2.clicked.connect(self.highlight_diff)


    def add_parent_item(self):
        # Get current selected in list widget
        # CustomTreeWidgetItem(self._tree, "test", is_tristate=True)
        self._tree.add_new_parent_item()


    def highlight_diff(self):
        self.current_dict = self._tree.derive_tree_items()

        if self.orig_dict != self.current_dict:
            # check for key difference
            diff = [k for k in self.current_dict if k not in self.orig_dict]


            if diff:
                # get the difference of top-level items
                for d in diff:
                    top_item = self._tree.findItems(d, QtCore.Qt.MatchExactly|QtCore.Qt.MatchRecursive)
                    #print aaa[0].childCount()
                    top_item[0].setTextColor(0, QtGui.QColor(255, 0, 0))

                    if top_item[0].childCount():
                        for n in range(top_item[0].childCount()):
                            top_item[0].child(n).setTextColor(0, QtGui.QColor(255, 0, 0))

            # to highlight the child diff. of existing top-items
            # issue with this portion if the new added item name already existed
            for k, v in self.current_dict.items():
                if k in self.orig_dict:
                    diff = set(v).difference(self.orig_dict.get(k), [])

                    for d in diff:
                        child_item = self._tree.findItems(d, QtCore.Qt.MatchExactly|QtCore.Qt.MatchRecursive)
                        child_item[0].setTextColor(0, QtGui.QColor(255, 0, 0))



if __name__ == "__main__":
    app = QtGui.QApplication(sys.argv)
    w = MainApp()
    w.show()
    sys.exit(app.exec_())
dissidia
  • 1,531
  • 3
  • 23
  • 53

1 Answers1

1

You can save in a role a flag indicating if it is a new item or not and change the color using a delegate:

import sys
from functools import partial
from PyQt4 import QtCore, QtGui
from collections import defaultdict

IsNewItemRole = QtCore.Qt.UserRole + 1000


class CustomTreeDelegate(QtGui.QStyledItemDelegate):
    @property
    def text_color(self):
        if not hasattr(self, "_text_color"):
            self._text_color = QtGui.QColor()
        return self._text_color

    @text_color.setter
    def text_color(self, color):
        self._text_color = color

    def initStyleOption(self, option, index):
        super(CustomTreeDelegate, self).initStyleOption(option, index)
        if self.text_color.isValid() and index.data(IsNewItemRole):
            option.palette.setBrush(QtGui.QPalette.Text, self.text_color)


class CustomTreeWidgetItem(QtGui.QTreeWidgetItem):
    def __init__(self, parent=None, text="", is_tristate=False, is_new_item=False):
        super(CustomTreeWidgetItem, self).__init__(parent)
        self.setText(0, text)
        flags = QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable
        if is_tristate:
            flags |= QtCore.Qt.ItemIsTristate
        else:
            self.setCheckState(0, QtCore.Qt.Unchecked)
        self.setFlags(self.flags() | flags)
        self.setData(0, IsNewItemRole, is_new_item)

    def setData(self, column, role, value):
        state = self.checkState(column)
        QtGui.QTreeWidgetItem.setData(self, column, role, value)
        if role == QtCore.Qt.CheckStateRole and state != self.checkState(column):
            tree_widget = self.treeWidget()
            if isinstance(tree_widget, CustomTreeWidget):
                tree_widget.itemToggled.emit(self, column)


class CustomTreeWidget(QtGui.QTreeWidget):
    itemToggled = QtCore.pyqtSignal(QtGui.QTreeWidgetItem, int)

    def __init__(self, widget=None):
        super(CustomTreeWidget, self).__init__(widget)
        self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.show_custom_menu)

    def show_custom_menu(self, pos):
        base_node = self.itemAt(pos)
        if base_node is None:
            return

        qmenu = QtGui.QMenu(self)
        remove_action = QtGui.QAction("Remove item", self)
        remove_action.triggered.connect(self.remove_selected_item)
        qmenu.addAction(remove_action)

        # The following options are only effected for top-level items
        # top-level items do not have `parent()`
        if base_node.parent() is None:
            add_new_child_action = QtGui.QAction("Add new sub item", self)
            add_new_child_action.triggered.connect(
                partial(self.adds_new_child_item, base_node)
            )
            # qmenu.addAction(add_new_child_action)
            qmenu.insertAction(remove_action, add_new_child_action)

        qmenu.exec_(self.mapToGlobal(pos))

    def add_item_dialog(self, title):
        text, ok = QtGui.QInputDialog.getText(
            self, "Add {0} Item".format(title), "Enter name for {0}-Item:".format(title)
        )
        if ok:
            return text

    def add_new_parent_item(self):
        input_text = self.add_item_dialog("Parent")
        if input_text:
            it = CustomTreeWidgetItem(
                self, input_text, is_tristate=True, is_new_item=True
            )

    def adds_new_child_item(self, base_node):
        input_text = self.add_item_dialog("Sub")
        if input_text:
            it = CustomTreeWidgetItem(base_node, input_text, is_new_item=True)
            self.setItemExpanded(base_node, True)
            it.setData(0, IsNewItemRole, True)

    def remove_selected_item(self):
        root = self.invisibleRootItem()
        for item in self.selectedItems():
            (item.parent() or root).removeChild(item)


class MainApp(QtGui.QWidget):
    def __init__(self, parent=None):
        super(MainApp, self).__init__(parent)

        # initial dictionary that is populated into the tree widget
        test_dict = {
            "menuA": ["a101", "a102"],
            "menuC": ["c101", "c102", "c103"],
            "menuB": ["b101"],
        }

        self._tree = CustomTreeWidget()
        self._tree.header().hide()

        self._tree_delegate = CustomTreeDelegate(self._tree)
        self._tree.setItemDelegate(self._tree_delegate)

        for pk, pv in sorted(test_dict.items()):
            parent = CustomTreeWidgetItem(self._tree, pk, is_tristate=True)

            for c in pv:
                child = CustomTreeWidgetItem(parent, c)

        # Expand the hierarchy by default
        self._tree.expandAll()

        tree_layout = QtGui.QVBoxLayout()
        self.btn1 = QtGui.QPushButton("Add new item")
        self.btn2 = QtGui.QPushButton("Highlight Diff.")
        tree_layout.addWidget(self._tree)
        tree_layout.addWidget(self.btn1)
        tree_layout.addWidget(self.btn2)

        main_layout = QtGui.QHBoxLayout(self)
        main_layout.addLayout(tree_layout)

        self.setup_connections()

    def setup_connections(self):
        self.btn1.clicked.connect(self.add_parent_item)
        self.btn2.clicked.connect(self.highlight_diff)

    def add_parent_item(self):
        # Get current selected in list widget
        # CustomTreeWidgetItem(self._tree, "test", is_tristate=True)
        self._tree.add_new_parent_item()

    def highlight_diff(self):
        self._tree_delegate.text_color = QtGui.QColor(255, 0, 0)
        self._tree.viewport().update()


if __name__ == "__main__":
    app = QtGui.QApplication(sys.argv)
    w = MainApp()
    w.show()
    sys.exit(app.exec_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • Could you kindly elaborate `IsNewItemRole = QtCore.Qt.UserRole + 1000`, especially the part on `+ 1000`? I may have seen it in other codes from time to time but never really got to understand it as I am not using it... Adding on, my impression of using ItemDataRole should always be direct, eg. if I am using `QtCore.Qt.UserRole`, either I write it as `32` (the value of that role) or `QtCore.Qt.UserRole` – dissidia Aug 15 '19 at 16:38
  • @dissidia 1) The "1000" is irrelevant since any number can be used but I use a "large number" to avoid collisions with other custom roles. 2) Never use numbers to indicate properties since it is less readable, when someone reads your code, they will ask themselves why "32"? What is "32" ?, and if you search the docs it will be difficult to find a reference, if instead you use Qt.UserRole then it will be easy to find the reference: "Qt::UserRole 0x0100 The first role that can be used for application- specific purposes. " https://doc.qt.io/qt-5/qt.html#ItemDataRole-enum, – eyllanesc Aug 15 '19 at 19:38
  • @dissidia another benefit is for example if Qt in the future adds more roles until role "32" is used for something else then they will change the UserRole to another number generating a problem to your code, in my code it will not generate problem since it will only move. In conclusion using UserRole adds readability + maintainability – eyllanesc Aug 15 '19 at 19:39
  • Thank you so much for the information! One question, going by what you have mentioned - is it possible to create roles of the same instance? Eg. `IsNewItemRole = QtCore.Qt.UserRole + 1000; IsOldItemRole = QtCore.Qt.UserRole + 100 `? Seeing that both uses the same `UserRole`... – dissidia Aug 15 '19 at 21:14
  • @dissidia QtCore.Qt.UserRole is an alias to a number, but that number is just a label to an information: indicate if it is a "new item" (True) or "old item" (False) so I don't see logic separating it. – eyllanesc Aug 15 '19 at 21:20
  • Gotcha, gonna try it out and see if it works in my other code as I was using the values `5` and `32`... Thank you – dissidia Aug 15 '19 at 21:27