Search code examples
pythonpyqtqtablewidget

How to set selecting slider for a QTableWidget?


I'm looking for solution of merge discrete slider and QTableWidget (see attached screenshot). Slider is used as selecting pointer(instead of default selecting highlighter). How it can be implemented using Qt (PyQt)?

Table slider


Solution

  • Small premise. Technically, according to StackOverflow standards, your question is not a very good one. I'll explain it at the end of this answer.

    Getting what you are asking for is not easy, most importantly because sliders are not built for that purpose (and there are many UX reasons for which you should not do that, go to User Experience to ask about them).

    The trick is to create a QSlider that has the table widget as a parent. Creating a widget with a parent ensures that the child widget will always be enclosed within the parent boundaries (this is only false for QMainWindow and QDialog descendants), as long as the widget is not added to the parent layout. This allows you to freely set its geometry (position and size).

    In the following example I'm adding an internal QSlider, but the main issue about this widget is aligning it in such a way that its value positions are aligned with the table contents.

    class GhostHeader(QtWidgets.QHeaderView):
        '''
        A "fake" vertical header that does not paint its sections
        '''
        def __init__(self, parent):
            super().__init__(QtCore.Qt.Vertical, parent)
            self.setSectionResizeMode(self.Fixed)
    
        def paintEvent(self, event):
            pass
    
    
    class SliderTable(QtWidgets.QTableWidget):
        def __init__(self, rows=0, columns=0, parent=None):
            super().__init__(rows, columns, parent)
            self.horizontalHeader().setStretchLastSection(True)
            self.setHorizontalHeaderLabels(['Item table'])
            self.setVerticalHeader(GhostHeader(self))
    
            # create a slider that is a child of the table; there is no layout, but
            # setting the table as its parent will cause it to be shown "within" it.
            self.slider = QtWidgets.QSlider(QtCore.Qt.Vertical, self)
            # by default, a slider has its maximum on the top, let's invert this
            self.slider.setInvertedAppearance(True)
            self.slider.setInvertedControls(True)
            # show tick marks at each slider value, on both sides
            self.slider.setTickInterval(1)
            self.slider.setTickPosition(self.slider.TicksBothSides)
            self.slider.setRange(0, max(0, self.rowCount() - 1))
            # not necessary, but useful for wheel and click interaction
            self.slider.setPageStep(1)
            # disable focus on the slider
            self.slider.setFocusPolicy(QtCore.Qt.NoFocus)
    
            self.slider.valueChanged.connect(self.selectRowFromSlider)
            self.slider.valueChanged.connect(self.updateSlider)
            self.verticalScrollBar().valueChanged.connect(self.updateSlider)
    
            self.model().rowsInserted.connect(self.modelChanged)
            self.model().rowsRemoved.connect(self.modelChanged)
    
        def selectRowFromSlider(self, row):
            if self.currentIndex().isValid():
                column = self.currentIndex().column()
            else:
                column = 0
            self.setCurrentIndex(self.model().index(row, column))
    
        def modelChanged(self):
            self.slider.setMaximum(max(0, self.rowCount() - 1))
            self.updateSlider()
    
        def updateSlider(self):
            slider = self.slider
            option = QtWidgets.QStyleOptionSlider()
            slider.initStyleOption(option)
            style = slider.style()
    
            # get the available extent of the slider
            available = style.pixelMetric(style.PM_SliderSpaceAvailable, option, slider)
            # compute the space between the top of the slider and the position of
            # the minimum value (0)
            deltaTop = (slider.height() - available) // 2
            # do the same for the maximum
            deltaBottom = slider.height() - available - deltaTop
    
            # the vertical center of the first item
            top = self.visualRect(self.model().index(0, 0)).center().y()
            # the vertical center of the last
            bottom = self.visualRect(self.model().index(self.model().rowCount() - 1, 0)).y()
    
            # get the slider width and adjust the size of the "ghost" vertical header
            width = self.slider.sizeHint().width()
            left = self.frameWidth() + 1
            self.verticalHeader().setFixedWidth(width // 2 + left)
    
            viewGeo = self.viewport().geometry()
            headerHeight = viewGeo.top()
            # create the rectangle for the slider geometry
            rect = QtCore.QRect(0, headerHeight + top, width, headerHeight + bottom - top // 2)
            # adjust to the values computed above
            rect.adjust(0, -deltaTop + 1, 0, -deltaBottom)
            # translate it so that its center will be between the vertical header and
            # the table contents
            rect.translate(left, 0)
            self.slider.setGeometry(rect)
    
            # set the mask, in case the item view is scrolled, so that the top of the
            # slider won't be shown in the horizontal header
            visible = self.rect().adjusted(0, viewGeo.top(), 0, 0)
            mask = QtGui.QPainterPath()
            topLeft = slider.mapFromParent(visible.topLeft())
            bottomRight = slider.mapFromParent(visible.bottomRight() + QtCore.QPoint(1, 1))
            mask.addRect(QtCore.QRectF(topLeft, bottomRight))
            self.slider.setMask(QtGui.QRegion(mask.toFillPolygon(QtGui.QTransform()).toPolygon()))
    
        def currentChanged(self, current, previous):
            super().currentChanged(current, previous)
            if current.isValid():
                self.slider.setValue(current.row())
    
        def resizeEvent(self, event):
            # whenever the table is resized (even when first shown) call the base
            # implementation (which is required for correct drawing of items and
            # selections), then update the slider
            super().resizeEvent(event)
            self.updateSlider()
    
    
    class Test(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            layout = QtWidgets.QVBoxLayout(self)
            self.table = SliderTable()
            self.table.setRowCount(4)
            self.table.setColumnCount(1)
            self.table.setHorizontalHeaderLabels(['Item table'])
            layout.addWidget(self.table)
            for row in range(self.table.rowCount()):
                item = QtWidgets.QTableWidgetItem('item {}'.format(row + 1))
                item.setTextAlignment(QtCore.Qt.AlignCenter)
                self.table.setItem(row, 0, item)
    

    Why this question is not that good?

    Well, it's dangerously close to the "I don't know how to do this, can you do it for me?" limit. You should provide any minimal, reproducible example (it doesn't matter if it doesn't work, you should do some research and show your efforts), and the question is a bit vague, even after some clarifications in the comment sections.
    Long story short: if it's too hard and you can't get it working, you probably still need some studying and exercise before you can achieve it. Be patient, study the documentation: luckily, Qt docs are usually well written, so it's just a matter of time.