qtpyqtqt5pyqt5

How can I support two separate double-clickable values per QTableView cell?


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?


Solution

  • 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.

    Screenshot

    As you can see from this screenshot, the left value in the top left corner is highlighted (due to hovering the mouse above it).

    Screenshot

    The Code

    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.

    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()
    

    Item Delegate

    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() 
    

    App Code

    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_()