Last active
March 16, 2024 13:00
-
-
Save oglops/1fa1b7c3a9d93ac6ff189cb2a556b64d to your computer and use it in GitHub Desktop.
filtered qtableview widgets
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import sys | |
from PySide2 import QtCore, QtGui, QtWidgets | |
from PySide2.QtCore import Qt | |
# https://www.pythonguis.com/tutorials/qtableview-modelviews-numpy-pandas/ | |
class TableModel(QtCore.QAbstractTableModel): | |
def __init__(self, data): | |
super(TableModel, self).__init__() | |
self._data = data | |
def data(self, index, role): | |
if role == Qt.DisplayRole: | |
return self._data[index.row()][index.column()] | |
elif role == Qt.TextAlignmentRole: | |
return Qt.AlignCenter | |
def rowCount(self, index): | |
return len(self._data) | |
def columnCount(self, index): | |
return len(self._data[0]) | |
class MainWindow(QtWidgets.QMainWindow): | |
def __init__(self): | |
super().__init__() | |
self.table = QtWidgets.QTableView() | |
data = [ | |
[4, 9, 2], | |
[1, 0, 0], | |
[3, 5, 0], | |
[3, 3, 2], | |
[7, 8, 9], | |
] | |
self.model = TableModel(data) | |
self.table.setModel(self.model) | |
self.setCentralWidget(self.table) | |
self.resize(330, 190) | |
self.table.setItemDelegateForColumn(2, StyledItemDelegateTriangle()) | |
self.table.setHorizontalHeader(HeaderView(Qt.Horizontal)) | |
# https://stackoverflow.com/questions/25489640/implement-paintsection-for-qheaderview-delivered-class | |
class HeaderView(QtWidgets.QHeaderView): | |
def paintSection(self, painter, rect, index): | |
painter.save() | |
super(HeaderView, self).paintSection(painter, rect, index) | |
if index in self.__filtered: | |
# painter.drawRect(rect.adjusted(2, 2, -2, -2)) | |
polygon = QtGui.QPolygon(3) | |
polygon[0] = QtCore.QPoint(rect.x() + 5, rect.y()) | |
polygon[1] = QtCore.QPoint(rect.x(), rect.y()) | |
polygon[2] = QtCore.QPoint(rect.x(), rect.y() + 5) | |
painter.restore() | |
painter.setBrush(QtGui.QBrush(QtGui.QColor(QtCore.Qt.darkGreen))) | |
painter.setPen(QtGui.QPen(QtGui.QColor(QtCore.Qt.darkGreen))) | |
painter.drawPolygon(polygon) | |
# https://stackoverflow.com/questions/19308940/how-to-draw-a-triangle-in-qtableview-cell-corner-to-show-the-model-data-can-be-s | |
class StyledItemDelegateTriangle(QtWidgets.QStyledItemDelegate): | |
def __init__(self, parent=None): | |
super(StyledItemDelegateTriangle, self).__init__(parent) | |
def paint(self, painter, option, index): | |
super(StyledItemDelegateTriangle, self).paint(painter, option, index) | |
polygon = QtGui.QPolygon(3) | |
polygon[0] = QtCore.QPoint(option.rect.x() + 5, option.rect.y()) | |
polygon[1] = QtCore.QPoint(option.rect.x(), option.rect.y()) | |
polygon[2] = QtCore.QPoint(option.rect.x(), option.rect.y() + 5) | |
painter.save() | |
painter.setRenderHint(painter.Antialiasing) | |
painter.setBrush(QtGui.QBrush(QtGui.QColor(QtCore.Qt.darkGreen))) | |
painter.setPen(QtGui.QPen(QtGui.QColor(QtCore.Qt.darkGreen))) | |
painter.drawPolygon(polygon) | |
painter.restore() | |
app = QtWidgets.QApplication(sys.argv) | |
window = MainWindow() | |
window.show() | |
app.exec_() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import sys | |
from PySide2 import QtCore, QtGui, QtWidgets | |
from PySide2.QtCore import Qt | |
word_file = "/usr/share/dict/words" | |
WORDS = open(word_file).read().splitlines() | |
import random | |
from functools import partial | |
class MultiListView(QtWidgets.QListView): | |
def __init__(self, parent=None): | |
super(MultiListView, self).__init__(parent) | |
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) | |
def keyPressEvent(self, event): | |
if event.key() == QtCore.Qt.Key_Space: | |
selected = self.selectedIndexes() | |
# skip the last one becaues it would be toggled again | |
for idx in selected[:-1]: | |
idx = self.model().mapToSource(idx) | |
item = self.model().sourceModel().itemFromIndex(idx) | |
curState = item.checkState() | |
state = ( | |
QtCore.Qt.Unchecked | |
if curState == QtCore.Qt.Checked | |
else QtCore.Qt.Checked | |
) | |
item.setCheckState(state) | |
return super(MultiListView, self).keyPressEvent(event) | |
# https://www.pythonguis.com/tutorials/qtableview-modelviews-numpy-pandas/ | |
class FilteredListView(QtWidgets.QWidget): | |
def __init__(self, parent=None): | |
super(FilteredListView, self).__init__(parent) | |
layout = QtWidgets.QVBoxLayout() | |
filterLayout = QtWidgets.QHBoxLayout() | |
self.filter = QtWidgets.QLineEdit() | |
self.filter.setToolTip("regex") | |
self.regexCheckbox = QtWidgets.QCheckBox(".*") | |
self.listView = MultiListView() | |
filterLayout.addWidget(self.filter) | |
filterLayout.addWidget(self.regexCheckbox) | |
layout.addLayout(filterLayout) | |
layout.addWidget(self.listView) | |
self.setLayout(layout) | |
class InnerProxyModel(QtCore.QSortFilterProxyModel): | |
def __init__(self, *args, **kwargs): | |
self.__filterStr = "" | |
self.__regexMode = False | |
return super(InnerProxyModel, self).__init__(*args, **kwargs) | |
def filterAcceptsRow(self, sourceRow, sourceParent): | |
if not self.__filterStr: | |
return True | |
index = self.sourceModel().index(sourceRow, 0, sourceParent) | |
modelStr = self.sourceModel().data(index, QtCore.Qt.DisplayRole) | |
if not self.__regexMode: | |
regex = QtCore.QRegExp( | |
"*%s*" % self.__filterStr, QtCore.Qt.CaseInsensitive | |
) | |
regex.setPatternSyntax(QtCore.QRegExp.Wildcard) | |
return regex.exactMatch(modelStr) | |
else: | |
regex = QtCore.QRegExp(self.__filterStr, QtCore.Qt.CaseInsensitive) | |
return regex.indexIn(modelStr) >= 0 | |
def setRegexMode(self, mode): | |
self.__regexMode = bool(mode) | |
def updateFilterStr(self, string): | |
self.__filterStr = string | |
self.invalidateFilter() | |
self.model = QtGui.QStandardItemModel(self) | |
self.proxy = InnerProxyModel() | |
self.proxy.setSourceModel(self.model) | |
self.listView.setModel(self.proxy) | |
self.filter.textChanged.connect(partial(self.proxy.updateFilterStr)) | |
self.regexCheckbox.toggled.connect(partial(self.proxy.setRegexMode)) | |
def populate(self, data): | |
for d in data: | |
item = QtGui.QStandardItem(d) | |
item.setCheckable(True) | |
self.model.appendRow(item) | |
class MainWindow(QtWidgets.QMainWindow): | |
def __init__(self, parent=None): | |
super(MainWindow, self).__init__(parent) | |
self.filterWidget = FilteredListView() | |
data = [random.choice(WORDS) for x in range(10)] | |
self.filterWidget.populate(data) | |
self.setCentralWidget(self.filterWidget) | |
self.resize(330, 190) | |
app = QtWidgets.QApplication(sys.argv) | |
window = MainWindow() | |
window.show() | |
app.exec_() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from logging import Filter | |
import sys | |
from PySide2 import QtCore, QtGui, QtWidgets | |
from PySide2.QtCore import Qt | |
word_file = "/usr/share/dict/words" | |
WORDS = open(word_file).read().splitlines() | |
import random | |
from functools import partial | |
HEADERS = ["Name", "Category", "Tags"] | |
class FilteredHeaderView(QtWidgets.QHeaderView): | |
def __init__(self, orientation=QtCore.Qt.Horizontal, parent=None): | |
super(FilteredHeaderView, self).__init__(orientation, parent) | |
self.__filtered = set() | |
self.setSectionsClickable(True) | |
# self.setHighlightSections(True) | |
def paintSection(self, painter, rect, index): | |
painter.save() | |
super(FilteredHeaderView, self).paintSection(painter, rect, index) | |
if index in self.__filtered: | |
# painter.drawRect(rect.adjusted(2, 2, -2, -2)) | |
polygon = QtGui.QPolygon(3) | |
polygon[0] = QtCore.QPoint(rect.x() + 5, rect.y()) | |
polygon[1] = QtCore.QPoint(rect.x(), rect.y()) | |
polygon[2] = QtCore.QPoint(rect.x(), rect.y() + 5) | |
painter.restore() | |
painter.setBrush(QtGui.QBrush(QtGui.QColor(QtCore.Qt.darkGreen))) | |
painter.setPen(QtGui.QPen(QtGui.QColor(QtCore.Qt.darkGreen))) | |
painter.drawPolygon(polygon) | |
def setFilteredColumn(self, column, clear=False): | |
if clear: | |
self.__filtered.remove(column) | |
else: | |
self.__filtered.add(column) | |
class PersistentMenu(QtWidgets.QMenu): | |
def mouseReleaseEvent(self, event): | |
action = self.activeAction() | |
if action and action.isEnabled(): | |
action.setEnabled(False) | |
super(PersistentMenu, self).mouseReleaseEvent(event) | |
action.setEnabled(True) | |
action.trigger() | |
else: | |
super(PersistentMenu, self).mouseReleaseEvent(event) | |
class TableModel(QtCore.QAbstractTableModel): | |
def __init__(self, data): | |
super(TableModel, self).__init__() | |
self._data = data | |
def data(self, index, role=Qt.DisplayRole): | |
if role == Qt.DisplayRole: | |
return self._data[index.row()][index.column()] | |
elif role == Qt.TextAlignmentRole: | |
return int(Qt.AlignCenter | Qt.AlignVCenter) | |
def rowCount(self, index=None): | |
return len(self._data) | |
def columnCount(self, index=None): | |
return len(self._data[0]) | |
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): | |
if role == Qt.DisplayRole and orientation == Qt.Horizontal: | |
return HEADERS[section] | |
return super(TableModel, self).headerData(section, orientation, role) | |
class MultiListView(QtWidgets.QListView): | |
def __init__(self, parent=None): | |
super(MultiListView, self).__init__(parent) | |
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) | |
def keyPressEvent(self, event): | |
if event.key() == QtCore.Qt.Key_Space: | |
selected = self.selectedIndexes() | |
# skip the last one becaues it would be toggled again | |
for idx in selected[:-1]: | |
idx = self.model().mapToSource(idx) | |
item = self.model().sourceModel().itemFromIndex(idx) | |
curState = item.checkState() | |
state = ( | |
QtCore.Qt.Unchecked | |
if curState == QtCore.Qt.Checked | |
else QtCore.Qt.Checked | |
) | |
item.setCheckState(state) | |
return super(MultiListView, self).keyPressEvent(event) | |
# https://www.pythonguis.com/tutorials/qtableview-modelviews-numpy-pandas/ | |
class FilteredListView(QtWidgets.QWidget): | |
filterChanged = QtCore.Signal(set) | |
def __init__(self, parent=None): | |
super(FilteredListView, self).__init__(parent) | |
layout = QtWidgets.QVBoxLayout() | |
filterLayout = QtWidgets.QHBoxLayout() | |
self.filter = QtWidgets.QLineEdit() | |
self.filter.setToolTip("regex") | |
self.regexCheckbox = QtWidgets.QCheckBox(".*") | |
self.listView = MultiListView() | |
filterLayout.addWidget(self.filter) | |
filterLayout.addWidget(self.regexCheckbox) | |
layout.addLayout(filterLayout) | |
layout.addWidget(self.listView) | |
self.setLayout(layout) | |
# store the tags choosen, choosing all tags ends up with empty filters | |
self.__filters = set() | |
self.__data = [] | |
class InnerProxyModel(QtCore.QSortFilterProxyModel): | |
def __init__(self, *args, **kwargs): | |
self.__filterStr = "" | |
self.__regexMode = False | |
return super(InnerProxyModel, self).__init__(*args, **kwargs) | |
def filterAcceptsRow(self, sourceRow, sourceParent): | |
if not self.__filterStr: | |
return True | |
index = self.sourceModel().index(sourceRow, 0, sourceParent) | |
modelStr = self.sourceModel().data(index, QtCore.Qt.DisplayRole) | |
if not self.__regexMode: | |
regex = QtCore.QRegExp( | |
"*%s*" % self.__filterStr, QtCore.Qt.CaseInsensitive | |
) | |
regex.setPatternSyntax(QtCore.QRegExp.Wildcard) | |
return regex.exactMatch(modelStr) | |
else: | |
regex = QtCore.QRegExp(self.__filterStr, QtCore.Qt.CaseInsensitive) | |
return regex.indexIn(modelStr) >= 0 | |
def setRegexMode(self, mode): | |
self.__regexMode = bool(mode) | |
def updateFilterStr(self, string): | |
self.__filterStr = string | |
self.invalidateFilter() | |
self.model = QtGui.QStandardItemModel(self) | |
self.proxy = InnerProxyModel() | |
self.proxy.setSourceModel(self.model) | |
self.listView.setModel(self.proxy) | |
self.filter.textChanged.connect(partial(self.proxy.updateFilterStr)) | |
self.regexCheckbox.toggled.connect(partial(self.proxy.setRegexMode)) | |
self.model.dataChanged.connect(partial(self.__dataChanged)) | |
def populate(self, data): | |
self.__data = data | |
for d in sorted(data, key=lambda x: x.lower()): | |
item = QtGui.QStandardItem(d) | |
item.setCheckable(True) | |
self.model.appendRow(item) | |
def __dataChanged(self, top_left, bottom_right, roles): | |
if QtCore.Qt.CheckStateRole in roles: | |
value = top_left.data() | |
checked = bool(top_left.data(role=QtCore.Qt.CheckStateRole)) | |
if checked: | |
self.__filters.add(value) | |
else: | |
self.__filters.remove(value) | |
if len(self.__filters) == len(self.__data): | |
# not filtering | |
filters = [] | |
else: | |
filters = self.__filters | |
self.filterChanged.emit(filters) | |
def restoreFilters(self, filters): | |
checked = [] | |
for r in range(self.model.rowCount()): | |
item = self.model.item(r) | |
if item.text() in filters: | |
item.setCheckState(QtCore.Qt.Checked) | |
checked.append(r) | |
# checked tags on top | |
if checked: | |
for i, r in enumerate(checked): | |
itemList = self.model.takeRow(r) | |
self.model.insertRow(i, itemList) | |
def clearFilters(self): | |
for r in range(self.model.rowCount()): | |
item = self.model.item(r) | |
item.setCheckState(QtCore.Qt.Unchecked) | |
class FilteredTableView(QtWidgets.QTableView): | |
def __init__(self, *args, **kwargs): | |
super(FilteredTableView, self).__init__(**kwargs) | |
self.filterHeaderView = FilteredHeaderView(QtCore.Qt.Horizontal) | |
self.setHorizontalHeader(self.filterHeaderView) | |
header = self.horizontalHeader() | |
header.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | |
header.customContextMenuRequested.connect(self.build_menu) | |
self.setSortingEnabled(True) | |
self.__filteredColumns = set() | |
self.__filtering = dict() | |
def enableFilter(self, column): | |
self.__filteredColumns.add(column) | |
def build_menu(self, pos): | |
col = self.horizontalHeader().logicalIndexAt(pos) | |
if col not in self.__filteredColumns: | |
return | |
menu = PersistentMenu() | |
if self.__filtering.get(col): | |
clearAction = menu.addAction("Clear Filters") | |
menu.addSeparator() | |
action = QtWidgets.QWidgetAction(menu) | |
filterWidget = FilteredListView() | |
# populate filterWidget based on all rows in current column | |
# data = [random.choice(WORDS) for x in range(10)] | |
colData = set() | |
proxy = self.model().sourceModel() | |
for r in range(proxy.rowCount()): | |
value = proxy.data(proxy.index(r, col)) | |
colData.update(value.split()) | |
filterWidget.populate(colData) | |
if self.__filtering.get(col): | |
filterWidget.restoreFilters(self.__filtering.get(col)) | |
action.setDefaultWidget(filterWidget) | |
menu.addAction(action) | |
filterWidget.filterChanged.connect(partial(self.__updateFilters, col)) | |
filterWidget.filterChanged.connect(partial(self.model().updateFilters, col)) | |
if self.__filtering.get(col): | |
clearAction.triggered.connect( | |
partial(self.__clearFilters, filterWidget, col) | |
) | |
menu.exec_(self.horizontalHeader().viewport().mapToGlobal(pos)) | |
def __updateFilters(self, column, filters): | |
self.__filtering[column] = filters | |
self.filterHeaderView.setFilteredColumn(column, clear=not bool(filters)) | |
# force instant repaint | |
self.filterHeaderView.viewport().update() | |
def __clearFilters(self, filterWidget, column): | |
filterWidget.clearFilters() | |
# self.filterHeaderView.setFilteredColumn(column, clear=True) | |
class MultiColumnProxyModel(QtCore.QSortFilterProxyModel): | |
def __init__(self, *args, **kwargs): | |
self.__filtering = {} | |
return super(MultiColumnProxyModel, self).__init__(*args, **kwargs) | |
def filterAcceptsRow(self, sourceRow, sourceParent): | |
if not self.__filtering: | |
return True | |
for col, filters in self.__filtering.items(): | |
if not filters: | |
continue | |
index = self.sourceModel().index(sourceRow, col, sourceParent) | |
modelStr = self.sourceModel().data(index, QtCore.Qt.DisplayRole) | |
# any of the match in filters would keep this row for current column | |
for f in filters: | |
if f == modelStr: | |
break | |
else: | |
return False | |
return True | |
def updateFilters(self, col, filters): | |
self.__filtering[col] = filters | |
self.invalidateFilter() | |
# https://stackoverflow.com/questions/65054604/pyqt5-qsortfilterproxymodel-sort-column-by-appropriate-data-type-int-float-da | |
def lessThan(self, left, right): | |
leftData = self.sourceModel().data(left) | |
rightData = self.sourceModel().data(right) | |
if leftData is None: | |
return True | |
elif rightData is None: | |
return False | |
elif type(leftData) != type(rightData): | |
# don't want to sort at all in these cases, False is just a copout ... | |
# should warn user | |
return False | |
return leftData.lower() < rightData.lower() | |
class MainWindow(QtWidgets.QMainWindow): | |
def __init__(self, parent=None): | |
super(MainWindow, self).__init__(parent) | |
self.table = FilteredTableView() | |
data = [[random.choice(WORDS) for y in range(3)] for x in range(10)] | |
self.model = TableModel(data) | |
self.proxy = MultiColumnProxyModel() | |
self.proxy.setSourceModel(self.model) | |
self.table.setModel(self.proxy) | |
for col in (1, 2): | |
self.table.enableFilter(col) | |
self.setCentralWidget(self.table) | |
self.resize(340, 340) | |
app = QtWidgets.QApplication(sys.argv) | |
window = MainWindow() | |
window.show() | |
app.exec_() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment