Search code examples
pythonpyqt5qtablewidget

Merging cells in an Excel-like table with PyQt5 not working as intended


I'm trying to make an Excel-like table in a GUI application with PyQt5. I'm struggling to add a merge/unmerge feature. After adding a few print statements, I noticed that merging a first group of cells will work, but when I try to merge a second group of cells, it will for some reason select only one cell (even though I've selected multiple) and the merge will not be successful. Once I unmerge the first group of cells, I can merge another group again, but thats it, just one group.

Has anyone ever encountered this issue or know a fix? I can't seem to find any helpful posts on stackoverflow or youtube.

I attempted to make it so the entire row would be selected. In that case, I was able to merge multiple different groups of rows. But I want it to be cells, like in Excel. And that doesn't work.

Adding self.tableWidget.clearSelection() didn't seem to help either.

import sys
from PyQt5.QtCore import Qt, QEvent
from PyQt5.QtWidgets import QApplication, QMainWindow, QTableWidget, QVBoxLayout, QWidget, QPushButton


class ExcelLikeTable(QMainWindow):
    def __init__(self):
        super().__init__()

        self.mergeButton = None
        self.unmergeButton = None
        self.tableWidget = QTableWidget()
        self.initUI()

    def initUI(self):
        self.setWindowTitle("Excel-like Table with PyQt5")
        self.setGeometry(100, 100, 800, 600)

        self.tableWidget.setColumnCount(10)
        self.tableWidget.setHorizontalHeaderLabels([f'Column {chr(65+i)}' for i in range(10)])
        self.tableWidget.setRowCount(1)

        self.tableWidget.clearSelection()
        self.tableWidget.setSelectionMode(QTableWidget.ContiguousSelection)
        self.tableWidget.setSelectionBehavior(QTableWidget.SelectItems)

        self.mergeButton = QPushButton("Merge Cells")
        self.unmergeButton = QPushButton("Unmerge Cells")
        self.mergeButton.clicked.connect(self.mergeCells)
        self.unmergeButton.clicked.connect(self.unmergeCells)

        layout = QVBoxLayout()
        layout.addWidget(self.tableWidget)
        layout.addWidget(self.mergeButton)
        layout.addWidget(self.unmergeButton)

        centralWidget = QWidget()
        centralWidget.setLayout(layout)
        self.setCentralWidget(centralWidget)

        self.tableWidget.installEventFilter(self)

    def mergeCells(self):
        if not self.tableWidget.selectedRanges():
            print("No cells selected for merging.")
            return

        selectedRange = self.tableWidget.selectedRanges()[0]
        topRow, leftColumn = selectedRange.topRow(), selectedRange.leftColumn()
        rowCount, columnCount = selectedRange.rowCount(), selectedRange.columnCount()

        print(
            f"Selected range - Top Row: {topRow}, Left Column: {leftColumn}, Row Count: {rowCount}, Column Count: {columnCount}")

        if rowCount == 1 and columnCount == 1:
            print("Only a single cell selected, no merge performed.")
            return

        self.tableWidget.setSpan(topRow, leftColumn, rowCount, columnCount)
        print(
            f"Merge completed from Cell {chr(65 + leftColumn)}{topRow + 1} to Cell {chr(65 + leftColumn + columnCount - 1)}{topRow + rowCount}")

    def unmergeCells(self):
        for i in range(self.tableWidget.rowCount()):
            for j in range(self.tableWidget.columnCount()):
                self.tableWidget.setSpan(i, j, 1, 1)

    def addRow(self):
        rowCount = self.tableWidget.rowCount()
        self.tableWidget.insertRow(rowCount)

    def eventFilter(self, source, event):
        if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Return:
            currentRow = self.tableWidget.currentRow()
            currentColumn = self.tableWidget.currentColumn()
            if currentRow == self.tableWidget.rowCount() - 1:
                self.addRow()
            self.tableWidget.setCurrentCell(currentRow + 1, currentColumn)
            return True
        return super(ExcelLikeTable, self).eventFilter(source, event)

    def debugPrintCellSpans(self):
        print("Debugging cell spans:")
        for i in range(self.tableWidget.rowCount()):
            for j in range(self.tableWidget.columnCount()):
                rowSpan = self.tableWidget.rowSpan(i, j)
                colSpan = self.tableWidget.columnSpan(i, j)
                if rowSpan > 1 or colSpan > 1:
                    print(f"Cell at ({i+1}, {chr(65+j)}) has row span: {rowSpan}, column span: {colSpan}")


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = ExcelLikeTable()
    ex.show()
    sys.exit(app.exec_())


Solution

  • As indicated by the name, selectedRanges returns a list of ranges, but the docs do not state under what precise circumstances multiple ranges will occur. It seems that after a merge, every cell in the subsequent selection forms a separate range, but whether this behaviour is as intended, or a bug, is unclear. Whatever the case may be, the behaviour of selectedIndexes is much more intuitive, so it would seem best to use that instead.

    I have shown how to fix your example using the selected indexes below. It appears that clearing the spans of the currently selected cells first works best, as otherwise it may result in rather confusing nested spans.

    class ExcelLikeTable(QMainWindow):
        ...
        def unmergeCells(self):
            self.tableWidget.clearSpans()
    
        def mergeCells(self):
            selection = self.tableWidget.selectedIndexes()
    
            if not selection:
                print("No cells selected for merging.")
                return
    
            if len(selection) == 1:
                print("Only a single cell selected, no merge performed.")
                return
    
            for index in selection:
                row, column = index.row(), index.column()
                if (self.tableWidget.rowSpan(row, column) > 1 or
                    self.tableWidget.columnSpan(row, column) > 1):
                    self.tableWidget.setSpan(row, column, 1, 1)
    
            # clearing the spans may have changed the selection
            selection = sorted(self.tableWidget.selectedIndexes())
    
            topRow, leftColumn = selection[0].row(), selection[0].column()
            bottomRow, rightColumn = selection[-1].row(), selection[-1].column()
            rowCount = bottomRow - topRow + 1
            columnCount = rightColumn - leftColumn + 1
    
            print(
                f"Selected range - Top Row: {topRow}, Left Column: {leftColumn}, Row Count: {rowCount}, Column Count: {columnCount}")
    
            self.tableWidget.setSpan(topRow, leftColumn, rowCount, columnCount)
            print(
                f"Merge completed from Cell {chr(65 + leftColumn)}{topRow + 1} to Cell {chr(65 + leftColumn + columnCount - 1)}{topRow + rowCount}")