Filtering a QTableView with QSortFilterProxyModel

January 28 2024

Background

Qt is a popular GUI toolkit for writing desktop applications. It has bindings for Python using either PySide2 or PyQt5 (which use essentially identical syntax). QTableView is the default class for table representations. It uses a QAbstractTableModel as the data source class. These can be sub classed to use whatever data backend you want. Here is some code using a pandas DataFrame as the data source. It’s not actually essential for the example, but could be extended with more complex filtering logic that uses pandas. The key to filtering tables is using a proxy model that sits on top of the main model. This QSortFilterProxyModel class has methods that allow filtering to be applied. When the proxy model is triggered by calling setFilterFixedString, for example, the table display updates. The underlying data is unchanged. Here are the methods for this class.

We show here a simple FilterWidget class that is just a text field and button. There is also a drop down option for choosing which column to filter by - this is set with the proxy.setFilterKeyColumn method. Other options could be added. To create more complex filtering machinery you may need a custom proxy model. Also note that with proxy models you may have to add some extra lines of code for sorting and selection to work properly, if you have specific code in your class already.

Imports

I used PySide2 here. Imports for PySide2 are slightly different than the PyQt5 library but the code is otherwise interchangeable.

import sys,os
import numpy as np
import pandas as pd

from PySide2 import QtCore
from PySide2.QtWidgets import *
from PySide2.QtGui import *
from PySide2.QtCore import Qt, QUrl, QObject, Signal, Slot
from pandas.api.types import is_datetime64_any_dtype as is_datetime

Code

class DataFrameModel(QtCore.QAbstractTableModel):
    def __init__(self, dataframe=None, *args):
        super(DataFrameModel, self).__init__()
        if dataframe is None:
            self.df = pd.DataFrame()
        else:
            self.df = dataframe
        self.bg = '#F4F4F3'
        self.rowcolors = None
        return

    def rowCount(self, parent=QtCore.QModelIndex()):
        return len(self.df.index)

    def columnCount(self, parent=QtCore.QModelIndex()):
        #if parent.isValid():
        #    return 0
        return len(self.df.columns.values)

    def data(self, index, role=QtCore.Qt.DisplayRole):
        """Edit or display roles. Handles what happens when the Cells
        are edited or what appears in each cell.
        """

        i = index.row()
        j = index.column()
        #print (self.df.dtypes)
        #coltype = self.df.dtypes[j]
        coltype = self.df[self.df.columns[j]].dtype
        isdate = is_datetime(coltype)
        if role == QtCore.Qt.DisplayRole:
            value = self.df.iloc[i, j]
            if isdate:
                return value.strftime(core.TIMEFORMAT)
            elif type(value) != str:
                if type(value) in [float,np.float64] and np.isnan(value):
                    return ''
                elif type(value) == float:
                    return value
                else:
                    return (str(value))
            else:
                return '{0}'.format(value)
        elif (role == QtCore.Qt.EditRole):
            value = self.df.iloc[i, j]
            if type(value) is str:
                try:
                    return float(value)
                except:
                    return str(value)
            if np.isnan(value):
                return ''

    def headerData(self, col, orientation, role=QtCore.Qt.DisplayRole):
        """What's displayed in the headers"""

        if len(self.df.columns) == 0:
            return
        if role == QtCore.Qt.DisplayRole:
            if orientation == QtCore.Qt.Horizontal:
                return str(self.df.columns[col])
            if orientation == QtCore.Qt.Vertical:
                value = self.df.index[col]
                if type( self.df.index) == pd.DatetimeIndex:
                    if not value is pd.NaT:
                        try:
                            return value.strftime(TIMEFORMAT)
                        except:
                            return ''
                else:
                    return str(value)
        return None

class DataFrameTable(QTableView):
    """
    QTableView with pandas DataFrame as model.
    """
    def __init__(self, parent=None, dataframe=None):

        QTableView.__init__(self)
        self.parent = parent
        vh = self.verticalHeader()
        vh.setVisible(True)
        vh.setDefaultSectionSize(28)
        vh.setMinimumWidth(20)
        vh.setMaximumWidth(500)
        hh = self.horizontalHeader()
        hh.setVisible(True)
        hh.setSectionsMovable(True)
        hh.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
        hh.setSelectionBehavior(QTableView.SelectColumns)
        hh.setSelectionMode(QAbstractItemView.ExtendedSelection)
        #hh.sectionClicked.connect(self.columnClicked)
        hh.setSectionsClickable(True)

        self.resizeColumnsToContents()
        self.setCornerButtonEnabled(True)

        tm = DataFrameModel(dataframe)
        self.model = tm

        self.proxy = QtCore.QSortFilterProxyModel()
        self.proxy.setSourceModel(self.model)
        self.setModel(self.proxy)
        self.proxy.sort(0, Qt.AscendingOrder)
        self.setWordWrap(False)
        self.setCornerButtonEnabled(True)
        return

    def applyFilters(self, query):
        """Apply filter to table"""

        self.proxy.setFilterFixedString(query)
        return

class FilterWidget(QWidget):
    """Simple filtering for tables"""

    def __init__(self, parent, table, title=None):
        super(FilterWidget, self).__init__(parent)
        self.parent = parent
        self.table = table
        self.createWidgets()
        self.setMaximumHeight(200)
        return

    def createWidgets(self):
        """Create widgets"""

        style = '''
            QWidget {
                font-size: 12px;
            }
            QScrollBar:vertical {
                width: 15px;
                margin: 1px 0 1px 0;
            }
            QScrollBar::handle:vertical {
                min-height: 20px;
            }
            QComboBox {
                combobox-popup: 0;
                max-height: 30px;
                max-width: 150px;
            }
            '''

        df = self.table.model.df
        cols = list(df.columns)
        cols.insert(0,'Any')

        self.setWindowTitle('Search')
        #self.main = QWidget()
        self.setStyleSheet(style)
        self.setMaximumHeight(200)

        l = self.layout = QVBoxLayout(self)
        l.setContentsMargins(0, 0, 0, 0)
        l.setSpacing(0)
        w = self.queryedit = QLineEdit(self)
        w.returnPressed.connect(self.apply)

        l.addWidget(QLabel("Query:"))
        l.addWidget(w)

        w = QWidget()
        l.addWidget(w)
        hb = QHBoxLayout(w)
        hb.addWidget(QLabel('Column:'))
        w = self.searchcolw = QComboBox()
        w.addItems(cols)
        hb.addWidget(w)

        btn = QPushButton('Search')
        btn.clicked.connect(self.apply)
        l.addWidget(btn)
        return

    def closeEvent(self, ce):
        self.clear()

    def apply(self):
        """Apply filters"""

        df = self.table.model.df
        proxy = self.table.proxy
        searchcol = self.searchcolw.currentText()
        text = self.queryedit.text()
        if searchcol == 'Any':
            proxy.setFilterKeyColumn(-1)
        else:
            c = df.columns.get_loc(searchcol)
            proxy.setFilterKeyColumn(c)
        proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
        self.table.applyFilters(text)
        return

    def clear(self):
        self.table.proxy.setFilterFixedString("")

Finally we make a small app for a working example:

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setGeometry(QtCore.QRect(100, 100, 800, 500))

        df = pd.read_csv('sample.csv')
        self.main = QWidget()
        layout = QVBoxLayout()
        #add table
        self.table = DataFrameTable(dataframe=df)
        layout.addWidget(self.table)
        #add filter widget
        self.filterw = FilterWidget(self, self.table)
        layout.addWidget(self.filterw)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)