aknuds1
aknuds1

Reputation: 68127

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?

Upvotes: 2

Views: 602

Answers (1)

aknuds1
aknuds1

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.

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

Upvotes: 4

Related Questions