Reputation: 68127
With PyQt5, I need to display two values per cell in a QTableView; basically, every column has to be split into two logical sub-columns. When hovering the mouse pointer above a value, its text should be highlighted, but not the other value within the same cell. Analogously, it should be possible to react to double-clicks of individual values within a cell. How do I implement this?
Upvotes: 2
Views: 602
Reputation: 68127
I solved the problem by implementing a slight variation on QTableView
, which makes use of a QStyledItemDelegate
subclass to paint the two different values (highlighted or not) and detect when each of them are double-clicked. Note that the two values per cell are represented as a semicolon-separated string in the model.
As you can see from this screenshot, the left value in the top left corner is highlighted (due to hovering the mouse above it).
There are three main parts to the code, the table view (a subclass of QTableView), the delegate (a subclass of QStyledItemDelegate) and the application code, which makes use of the table view.
import sys
from PyQt5 import QtWidgets, QtGui, QtCore
class TableView(QtWidgets.QTableView):
def __init__(self, parent):
super(TableView, self).__init__(parent)
self.__pressed_index = None
self.__entered_index = None
self.setItemDelegate(SplitCellDelegate(self))
self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
for header in (self.horizontalHeader(), self.verticalHeader()):
header.installEventFilter(self)
def mouseDoubleClickEvent(self, event):
super(TableView, self).mouseDoubleClickEvent(event)
index = self.indexAt(event.pos())
if not index.isValid() or not self.__is_index_enabled(index) or self.__pressed_index != index:
me = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonPress, event.localPos(), event.windowPos(), event.screenPos(),
event.button(), event.buttons(), event.modifiers())
return
index_rel_pos = self.__get_index_rel_pos(event, index)
delegate = self.itemDelegate(index)
delegate.double_clicked(index, index_rel_pos)
def mousePressEvent(self, event):
super(TableView, self).mousePressEvent(event)
self.__pressed_index = self.indexAt(event.pos())
def mouseMoveEvent(self, event):
super(TableView, self).mouseMoveEvent(event)
if self.state() == self.ExpandingState or self.state() == self.CollapsingState or self.state() == self.DraggingState:
return
index = self.indexAt(event.pos())
if self.__entered_index is not None and index != self.__entered_index:
# We've left the currently entered index
self.itemDelegate(self.__entered_index).left(self.__entered_index)
self.__entered_index = None
if not index.isValid() or not self.__is_index_enabled(index):
# No index is currently hovered above
return
self.__entered_index = index
index_rel_pos = self.__get_index_rel_pos(event, index)
self.itemDelegate(index).mouse_move(index, index_rel_pos)
def leaveEvent(self, event):
super(TableView, self).leaveEvent(event)
self.__handle_mouse_exit()
def __handle_mouse_exit(self):
if self.__entered_index is None:
return
self.itemDelegate(self.__entered_index).left(self.__entered_index)
self.__entered_index = None
def eventFilter(self, obj, event):
if (obj is not self.horizontalHeader() and obj is not self.verticalHeader()) or \
event.type() not in (QtCore.QEvent.Enter,):
return super(TableView, self).eventFilter(obj, event)
self.__handle_mouse_exit()
return False
def __get_index_rel_pos(self, event, index):
"""Get position relative to index."""
# Get index' y offset
pos = event.pos()
x = pos.x()
y = pos.y()
while self.indexAt(QtCore.QPoint(x, y-1)) == index:
y -= 1
while self.indexAt(QtCore.QPoint(x-1, y)) == index:
x -= 1
return QtCore.QPoint(pos.x()-x, pos.y()-y)
def __is_index_enabled(self, index):
return index.row() >= 0 and index.column() >= 0 and index.model()
class SplitCellDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent):
super(SplitCellDelegate, self).__init__(parent)
self.__view = parent
parent.setMouseTracking(True)
self.__hor_padding = 10
self.__above_value1 = self.__above_value2 = None
self.__rect = None
def paint(self, painter, option, index):
#print('Painting; width: {}'.format(option.rect.width()))
painter.setRenderHint(QtGui.QPainter.Antialiasing)
#print('Painting {},{}'.format(index.row(), index.column()))
rect = option.rect
# Copy the rect in case it changes
self.__rect = QtCore.QRect(option.rect)
if option.state & QtWidgets.QStyle.State_Selected:
painter.fillRect(rect, option.palette.highlight())
value1, value2 = self.__split_text(index)
value1_start, separator_start, value2_start = [x + rect.x() for x in self.__compute_offsets(index)]
if self.__above_value1 == index:
self.__set_bold_font(painter)
#print('Drawing value1 highlighted')
#print('Drawing \'{}\' from {} to {}'.format(self.__value1, value1_start, separator_start))
text_rect = QtCore.QRectF(0, rect.y(), rect.width(), rect.height())
painter.drawText(text_rect.translated(value1_start, 0), value1, QtGui.QTextOption(QtCore.Qt.AlignVCenter))
if self.__above_value1 == index:
painter.restore()
painter.drawText(text_rect.translated(separator_start, 0), '|', QtGui.QTextOption(QtCore.Qt.AlignVCenter))
if self.__above_value2 == index:
self.__set_bold_font(painter)
#print('Drawing value2 highlighted')
#else:
#print('Not drawing highlighted')
painter.drawText(text_rect.translated(value2_start, 0), value2, QtGui.QTextOption(QtCore.Qt.AlignVCenter))
if self.__above_value2 == index:
painter.restore()
def sizeHint(self, option, index):
value1, value2 = self.__split_text(index)
font = QtGui.QFont(self.__view.font())
font.setBold(True)
fm = QtGui.QFontMetrics(font)
return QtCore.QSize(self.__hor_padding*2 + fm.width('{}|{}'.format(value1, value2)),
15*2 + fm.height())
@staticmethod
def __set_bold_font(painter):
painter.save()
font = QtGui.QFont(painter.font())
font.setBold(True)
painter.setFont(font)
@staticmethod
def __split_text(index):
text = index.data(QtCore.Qt.DisplayRole).split(';')
value1 = text[0] + ' '
value2 = ' ' + text[1]
return value1, value2
def mouse_move(self, index, pos):
if self.__rect is None:
return
value1_start, separator_start, value2_start = self.__compute_offsets(index)
x = pos.x()
#print('Mouse move in cell: {} ({} | {})'.format(x, separator_start, value2_start))
if x < separator_start:
if self.__above_value1 == index:
return
self.__above_value1 = index
self.__above_value2 = None
#print('Above value1')
self.__repaint()
elif x >= value2_start:
if self.__above_value2 == index:
return
self.__above_value2 = index
self.__above_value1 = None
#print('Above value2')
self.__repaint()
elif self.__above_value1 is not None or self.__above_value2 is not None:
self.__above_value1 = self.__above_value2 = None
#print('Above separator')
self.__repaint()
def left(self, index):
#print('Index {},{} left'.format(index.row(), index.column()))
self.__above_value1 = self.__above_value2 = None
self.__repaint()
def double_clicked(self, index, pos):
x = pos.x()
value1_start, separator_start, value2_start = self.__compute_offsets(index)
if x < separator_start:
print('Index {},{} double-clicked at value 1'.format(index.row(), index.column()))
elif x >= value2_start:
print('Index {},{} double-clicked at value 2'.format(index.row(), index.column()))
def __compute_offsets(self, index):
rect = self.__rect
value1, value2 = self.__split_text(index)
#print('Computing offsets; width: {}'.format(rect.width()))
font = QtGui.QFont(self.__view.font())
font.setBold(True)
fm = QtGui.QFontMetrics(font)
value2_start = rect.width() - fm.width(value2) - self.__hor_padding
separator_start = value2_start - fm.width('|')
value1_start = separator_start - fm.width(value1)
#print('Offsets for {},{} are {}, {}, {}'.format(index.row(), index.column(), value1_start, separator_start, value2_start))
return value1_start, separator_start, value2_start
def __repaint(self):
# TODO: Repaint only cell in question
self.__view.viewport().repaint()
class Window(QtWidgets.QMainWindow):
def __init__(self):
super(Window, self).__init__()
table_view = self.__set_up_table()
w = QtWidgets.QWidget()
vbox = QtWidgets.QVBoxLayout(w)
vbox.addWidget(table_view)
self.setCentralWidget(w)
def __set_up_table(self):
rows = 4
cols = 4
table = QtGui.QStandardItemModel()
for row in range(rows):
l = [QtGui.QStandardItem('Row {};Column {}'.format(row, col)) for col in range(cols)]
table.appendRow(l)
table.setVerticalHeaderItem(row, QtGui.QStandardItem('Row {}'.format(row)))
for col in range(cols):
table.setHorizontalHeaderItem(col, QtGui.QStandardItem('Column {}'.format(col)))
table_view = TableView(self)
table_view.setModel(table)
table_view.setSortingEnabled(True)
table_view.resizeColumnsToContents()
return table_view
app = QtWidgets.QApplication(sys.argv)
w = Window()
w.show()
app.exec_()
Upvotes: 4