python-3.xpysideqtreeviewqlistviewqstandarditemmodel

Displaying data in different widgets: PySide6


My question is what would be the most efficient way to display an object (in my case Project class) attributes in different widgets? I read that there are proxy models, but I couldn't figure out if it would be more efficient in my case to use proxy models rather then three separate QStandardItemModels (one model per widget)? I also don't understand how to implement proxy models in my case. The real case scenario and a simplified example are described below.

I need to display different attributes of an object (Project class) in different widgets. In real case scenario Project object has four attributes: two data frames (df1 and df2) and two strings (project and folder). project attribute is unique for each project, while folder attribute can be the same for different projects. df1 has 4 columns and looks likes this (Item Name column can have duplicate values, Number column will not have duplicate values):

  Item Name  Checked  Number  Checked Number
0    Banana     True  UHOSD7           False
1     Apple    False  T71HA0           False
2    Banana     True  WOGUAJ           False
3      Plum    False  C0CBN5           False

Another data frame looks like this (no duplicate values in "Parameters" column):

  Parameters  Values
0       par1       6
1       par2       5
2       par3       6

I will have following widgets in the software: one QTreeView(i.e.tree) and three QListViews(list1, list2 and list3). In tree I need to display project attributes for different Project objects grouped by folder attribute (as shown on the screenshot below). Items in tree can be added or removed.

In list1, list2 and list3 I need to display different columns of df1 and df2 attrbutes of Project object that is currently selected in the tree. In list1 I need to display unique values of Item Name column of df1. Selecting an item in a list1 should display in list2 items from Number column for rows where value in Item Name column is equal to the value of the selected item in list1 (for example if "Banana" selected in list1, list2 should contain UHOSD7 and WOGUAJ). Items in list1 and list2 can not be added/removed, but items are checkable. If check status of item is changed then information about current check status need to be updated in df1 (columns Checked or Checked Number).

In list3 I need to display Parameters column of df2. In this list items are unchackable and items can not be removed/added.

By selecting an item in any of the lists (list1, list2 and list3) I need to have access to any attribute of Project object to which selected item belongs (as mentioned above no removing/adding of items in df1, df2, project, folder needed).

As an example, I have a QTreeView and two QListViews. Each project has three attributes: project name, folder and one dataframe (``df1```).

To display items in the widgets I have created three models:

CustomTreeModel subclassing QStandardItemModel displays data in tree;

NameListModel subclassing QStandardItemModel displays data in list1;

NumberListModel subclassing QStandardItemModel displays data in list2;

To update information in the df1 about check status of items in list1 and list2 of selectedProject item and to handle change of item selection in tree and list1 I use four functions:

handle_tree_item_selection_changed

handle_name_item_checked

handle_name_selection_changed

handle_number_item_checked

Therefore, I have implemented three models: one separate model per widget. I guess it is not the most efficient way to do it.

Example of df1 attribute for Project 1:

  Item Name  Checked  Number  Checked Number
0    Banana     True  UHOSD7           False
1     Apple    False  T71HA0           False
2    Banana     True  WOGUAJ           False
3      Plum    False  C0CBN5           False

GUI: enter image description here

Code:

import sys
import pandas as pd
from PySide6.QtWidgets import QApplication, QMainWindow, QListView, QTreeView, QWidget, QVBoxLayout
from PySide6.QtGui import QStandardItemModel, QStandardItem
from PySide6.QtCore import Qt
import random
import string

class ProjectItem(object):
    def __init__(self, project, folder):
        self.project = project
        self.folder = folder
        self.df = pd.DataFrame({'Item Name': ["Banana", "Apple", "Banana", "Plum"],
                      'Checked': [bool(random.getrandbits(1)) for i in range(4)], 'Number': [self.id_generator() for i in
                               range(4)], 'Checked Number': [bool(random.getrandbits(1)) for i in range(4)]})

    def id_generator(self, size=6, chars=string.ascii_uppercase + string.digits):
        ...
        return ''.join(random.choice(chars) for _ in range(size))
class NameListModel(QStandardItemModel):
    def __init__(self, data_frame=None):
        super().__init__()
        self._data_frame = data_frame

        # Populate the list view with unique item names
        if self._data_frame:
            unique_items = data_frame['Item Name'].unique()
            for item_name in unique_items:
                item = QStandardItem(item_name)
                item.setCheckable(True)
                self.appendRow(item)

    @property
    def data_frame(self):
        return self._data_frame

    @data_frame.setter
    def data_frame(self, new_data_frame):
        self._data_frame = new_data_frame
        # Update the model (repopulate or modify existing items)
        self.update_model()

    def update_dataframe(self, item):
        item_name = item.text()
        checked = item.checkState() == Qt.Checked
        self.data_frame.loc[self.data_frame['Item Name'] == item_name, 'Checked'] = checked

    def update_model(self):
        self.clear()
        unique_items = self.data_frame['Item Name'].unique()
        for item_name in unique_items:
            item = QStandardItem(item_name)
            checked = self.data_frame.loc[self.data_frame['Item Name'] == item_name, 'Checked'].iloc[0]
            item.setCheckState(Qt.Checked if checked else Qt.Unchecked)
            item.setCheckable(True)
            self.appendRow(item)

    def selected_item(self, index):
        item = self.itemFromIndex(index)
        if item:
            # Retrieve custom data (ProjectItem) from UserRole
            return item.data(Qt.DisplayRole)


class NumberListModel(QStandardItemModel):
    def __init__(self, data_frame=None):
        super().__init__()
        self._data_frame = data_frame

        # Populate the list view with unique item names
        if self._data_frame:
            items = data_frame['Number']
            for item_name in items:
                item = QStandardItem(item_name)
                item.setCheckable(True)
                self.appendRow(item)

    @property
    def data_frame(self):
        return self._data_frame

    @data_frame.setter
    def data_frame(self, new_data_frame):
        self._data_frame = new_data_frame
        # Update the model (repopulate or modify existing items)
        self.update_model()

    def update_dataframe(self, item):
        item_name = item.text()
        checked = item.checkState() == Qt.Checked
        self.data_frame.loc[self.data_frame['Number'] == item_name, 'Checked Number'] = checked

    def update_model(self):
        # Implement logic to update the model when data_frame changes
        # For example, clear existing items and repopulate with new data_frame
        self.clear()
        items = self.data_frame['Number']
        for item_name in items:
            item = QStandardItem(item_name)
            checked = self.data_frame.loc[self.data_frame['Number'] == item_name, 'Checked Number'].iloc[0]
            item.setCheckState(Qt.Checked if checked else Qt.Unchecked)
            item.setCheckable(True)
            self.appendRow(item)

    def selected_item(self, index):
        item = self.itemFromIndex(index)
        if item:
            # Retrieve custom data (ProjectItem) from UserRole
            return item.data(Qt.DisplayRole)

class CustomTreeModel(QStandardItemModel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setHorizontalHeaderLabels(["Project"])
        self.folder_items = {}  # Store folder items for grouping

    def add_project(self, project, folder):
        new_project = ProjectItem(project, folder)
        if folder not in self.folder_items:
            folder_item = QStandardItem(folder)
            self.appendRow([folder_item])
            self.folder_items[folder] = folder_item
        else:
            folder_item = self.folder_items[folder]

        project_item = QStandardItem(project)
        # Set custom data (ProjectItem) as UserRole
        project_item.setData(new_project, Qt.UserRole)

        # Insert the project item using beginInsertRows and endInsertRows
        row = folder_item.rowCount()
        self.beginInsertRows(folder_item.index(), row, row)
        folder_item.appendRow([project_item])
        self.endInsertRows()

        # Disable item editing
        folder_item.setEditable(False)
        project_item.setEditable(False)

    def selected_item(self, index):
        item = self.itemFromIndex(index)
        if item:
            # Retrieve custom data (ProjectItem) from UserRole
            return item.data(Qt.UserRole)

def handle_tree_item_selection_changed(new_selection, old_selection):
    index = new_selection.indexes()[0]
    project_item = treemodel.selected_item(index)
    if project_item:
        print(f"Selected Project: {project_item.project}, Folder: {project_item.folder}")
        name_list_model.data_frame = project_item.df

def handle_name_item_checked(item):
    item_name = item.text()
    checked = item.checkState() == Qt.Checked
    index = tree_view.selectionModel().currentIndex()
    project_item = treemodel.selected_item(index)
    project_item.df.loc[project_item.df['Item Name'] == item_name, 'Checked'] = checked
def handle_name_selection_changed(new_selection, old_selection):
    index = tree_view.selectionModel().currentIndex()
    project_item = treemodel.selected_item(index)
    name_index = new_selection.indexes()[0]
    name_item = name_list_model.selected_item(name_index)
    if name_item:
        number_list_model.data_frame = project_item.df[project_item.df['Item Name'] == name_item]

def handle_number_item_checked(item):
    item_name = item.text()
    checked = item.checkState() == Qt.Checked
    index = tree_view.selectionModel().currentIndex()
    project_item = treemodel.selected_item(index)
    project_item.df.loc[project_item.df['Number'] == item_name, 'Checked Number'] = checked


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = QMainWindow()
    window.setWindowTitle("Custom Tree Model Example")
    window.setGeometry(100, 100, 600, 400)

    tree_view = QTreeView()
    central_widget = QWidget()
    layout = QVBoxLayout(central_widget)
    layout.addWidget(tree_view)
    name_list_view = QListView()
    layout.addWidget(name_list_view)
    number_list_view = QListView()
    layout.addWidget(number_list_view)
    window.setCentralWidget(central_widget)

    treemodel = CustomTreeModel()
    tree_view.setModel(treemodel)
    tree_view.selectionModel().selectionChanged.connect(handle_tree_item_selection_changed)

    name_list_model = NameListModel()
    name_list_view.setModel(name_list_model)
    name_list_model.itemChanged.connect(handle_name_item_checked)
    name_list_view.selectionModel().selectionChanged.connect(handle_name_selection_changed)

    number_list_model = NumberListModel()
    number_list_view.setModel(number_list_model)
    number_list_model.itemChanged.connect(handle_number_item_checked)

    treemodel.add_project("Project 1", "Folder 1")
    treemodel.add_project("Project 2", "Folder 1")
    treemodel.add_project("Project 2", "Folder 3")

    window.show()
    sys.exit(app.exec())

Solution

  • Your approach is not completely off.

    The main problem comes from the fact that you have some fields that are not unique, but you want to make them such by "grouping" their item values.

    This makes it necessary to use separated models that expose different index counts and eventually map their values accordingly.

    Note that using QStandardItemModel for a source data frame that probably needs to be updated with user interaction is normally not a good choice. A QAbstractItemModel subclass is normally much better, and in this specific case QAbstractListModel is the most logical choice.

    The most important benefit is that this makes reading and updating the data frame more immediate, without the need of using signals.

    I'd suggest this structure, then:

    Both QAbstractListModels actually keep a list of their contents (unique names, or numbers) as main data source for the view, which is then used as reference with the actual original data frame in order to get and set the real values; most importantly, the check state, but also the name associated with the number in the NumberListModel (used for filtering).

    ProjectRole = Qt.UserRole + 1
    NameRole = ProjectRole + 10
    
    class ProjectModel(QStandardItemModel):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setHorizontalHeaderLabels(["Project"])
            self.folder_items = {}  # Store folder items for grouping
    
        def add_project(self, project, folder):
            new_project = ProjectItem(project, folder)
            if folder not in self.folder_items:
                folder_item = QStandardItem(folder)
                self.appendRow([folder_item])
                self.folder_items[folder] = folder_item
            else:
                folder_item = self.folder_items[folder]
    
            project_item = QStandardItem(project)
            project_item.setData(new_project, ProjectRole)
    
            folder_item.appendRow([project_item])
    
    
    class NameListModel(QAbstractListModel):
        def __init__(self, data_frame):
            super().__init__()
            self.df = data_frame
            self._names = data_frame['Item Name'].unique()
    
        def flags(self, index):
            return super().flags(index) | Qt.ItemIsUserCheckable
    
        def rowCount(self, parent=None):
            return len(self._names)
    
        def data(self, index, role=Qt.DisplayRole):
            if role == Qt.DisplayRole:
                return self._names[index.row()]
            elif role == Qt.CheckStateRole:
                name = self._names[index.row()]
                checked = self.df.loc[
                    self.df['Item Name'] == name, 'Checked'
                ].iloc[0]
                return Qt.Checked if checked else Qt.Unchecked
    
        def setData(self, index, value, role=Qt.EditRole):
            if role == Qt.CheckStateRole:
                name = self._names[index.row()]
                self.df.loc[
                    self.df['Item Name'] == name, 'Checked'
                ] = value == Qt.Checked
                self.dataChanged.emit(index, index)
                return True
            return False
    
    
    class NumberListModel(QAbstractListModel):
        def __init__(self, data_frame=None):
            super().__init__()
            self.df = data_frame
            self._numbers = data_frame['Number']
    
        def flags(self, index):
            return super().flags(index) | Qt.ItemIsUserCheckable
    
        def rowCount(self, parent=None):
            return len(self._numbers)
    
        def data(self, index, role=Qt.DisplayRole):
            if role == Qt.DisplayRole:
                return self._numbers[index.row()]
            elif role == Qt.CheckStateRole:
                number = self._numbers[index.row()]
                checked = self.df.loc[
                    self.df['Number'] == number, 'Checked'
                ].iloc[0]
                return Qt.Checked if checked else Qt.Unchecked
            elif role == NameRole:
                number = self._numbers[index.row()]
                return self.df.loc[
                    self.df['Number'] == number, 'Item Name'
                ].iloc[0]
    
        def setData(self, index, value, role=Qt.EditRole):
            if role == Qt.CheckStateRole:
                name = self._numbers[index.row()]
                self.df.loc[
                    self.df['Number'] == name, 'Checked'
                ] = value == Qt.Checked
                self.dataChanged.emit(index, index)
                return True
            return False
    
    
    class NumberFilteredModel(QSortFilterProxyModel):
        _filter = None
        def __init__(self, source):
            super().__init__()
            self.source = source
            self.setSourceModel(source)
    
        def invalidate(self):
            self.setFilter()
    
        def setFilter(self, filter=None):
            if self._filter == filter:
                return
            self._filter = filter
            self.invalidateFilter()
    
        def filterAcceptsRow(self, row, parent):
            if self._filter is None:
                return False
            return self.source.index(row, 0).data(NameRole) == self._filter
    

    The Project object also provides a simple helper function that resets the filter of the number model.

    def generate_id(size=6, chars=string.ascii_uppercase+string.digits):
        return ''.join(random.choice(chars) for _ in range(size))
    
    class ProjectItem(object):
        def __init__(self, project, folder):
            self.project = project
            self.folder = folder
            self.df = pd.DataFrame({
                'Item Name': ["Banana", "Apple", "Banana", "Plum"],
                'Checked': [bool(random.getrandbits(1)) for i in range(4)], 
                'Number': [generate_id() for i in range(4)], 
                'Checked Number': [bool(random.getrandbits(1)) for i in range(4)]
            })
    
            self.name_model = NameListModel(self.df)
            self.number_model = NumberFilteredModel(NumberListModel(self.df))
    
        def invalidate_number_filter(self):
            self.number_model.invalidate()
    

    Finally, the UI implementation. As a related note, while using simple functions and global objects is obviously not strictly forbidden, using a proper class with its own methods is not only preferable, but also better in terms of code/object structure, readability and maintenance.

    class TestWindow(QMainWindow):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setWindowTitle("Custom Tree Model Example")
    
            self.tree_view = QTreeView()
            self.tree_view.setEditTriggers(self.tree_view.NoEditTriggers)
    
            self.projectModel = ProjectModel()
            self.tree_view.setModel(self.projectModel)
    
            self.projectModel.add_project("Project 1", "Folder 1")
            self.projectModel.add_project("Project 2", "Folder 1")
            self.projectModel.add_project("Project 2", "Folder 3")
    
            self.name_list_view = QListView()
            self.number_list_view = QListView()
    
            central_widget = QWidget()
            layout = QVBoxLayout(central_widget)
            layout.addWidget(self.tree_view)
            layout.addWidget(self.name_list_view)
            layout.addWidget(self.number_list_view)
            self.setCentralWidget(central_widget)
    
            self.tree_view.selectionModel().selectionChanged.connect(
                self.project_selected)
    
        def project_selected(self, selection):
            indexList = selection.indexes()
            if not indexList:
                return
            project_item = indexList[-1].data(ProjectRole)
            if project_item:
                self.name_list_view.setModel(project_item.name_model)
                self.number_list_view.setModel(project_item.number_model)
                project_item.invalidate_number_filter()
                # reconnect, since setModel() changes the selection model
                self.name_list_view.selectionModel().selectionChanged.connect(
                    self.name_selected)
    
        def name_selected(self, selection):
            indexList = selection.indexes()
            if not indexList:
                return
            name = indexList[-1].data()
            self.number_list_view.model().setFilter(name)
    
    
    if __name__ == "__main__":
        app = QApplication(sys.argv)
        window = TestWindow()
    
        window.show()
        sys.exit(app.exec())