2025, Dec 08 19:00

Build Reliable PyQt6 QTableView Header Popups for Column Filters: QDialog Popup, Proper Positioning, Cleanup

Learn how to implement PyQt6 QTableView header popups for column filters with QDialog Popup, correct coordinate mapping, and deleteLater for reliable behavior.

Building spreadsheet-like interactions in PyQt6 often starts with a simple prototype: click a column header, show a small helper panel with filters or hints, then dismiss it when focus is lost. The quick approach works, but it can stumble on subtle UI details: correct positioning when scrolling, expected popup behavior, and lifetime management of transient widgets. Here’s how to make it solid.

What we’re trying to achieve

We want a QTableView that shows a floating popup when a header section is clicked. The initial version relies on a frameless QWidget that closes on focus loss. It displays, it moves, it closes—so far so good—but the implementation can be improved to be more idiomatic and reliable.

Baseline example that illustrates the approach

The following code demonstrates the idea using a frameless window that appears below the clicked header. Names are intentionally simple and the logic mirrors a minimal working setup.

from PyQt6.QtWidgets import QApplication, QMainWindow, QTableView, QWidget, QLabel, QVBoxLayout
from PyQt6.QtCore import Qt, QAbstractTableModel, QPoint, QTimer

class GridModel(QAbstractTableModel):
    def __init__(self, rows):
        super().__init__()
        self._rows = rows

    def rowCount(self, parent):
        return len(self._rows)

    def columnCount(self, parent):
        return len(self._rows[0])

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            return self._rows[index.row()][index.column()]

class HoverPanel(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowFlags(Qt.WindowType.Tool | Qt.WindowType.FramelessWindowHint)
        self.setWindowState(Qt.WindowState.WindowActive)
        self.setStyleSheet("background-color: lightgray; border: 1px solid black;")
        self.setFixedSize(300, 200)
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self._active = False

    def focusOutEvent(self, event):
        QTimer.singleShot(500, self.close)
        super().focusOutEvent(event)

    def close(self):
        self._active = False
        return super().close()

class MainView(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self._table = QTableView(self)
        self._table.horizontalHeader()
        self.setCentralWidget(self._table)

        sample = [["Apple", "Red"], ["Banana", "Yellow"], ["Cherry", "Red"], ["Grape", "Purple"]]
        self._model = GridModel(sample)
        self._table.setModel(self._model)

        hdr = self._table.horizontalHeader()
        hdr.sectionClicked.connect(self._show_overlay)
        self._overlay = HoverPanel()

    def _show_overlay(self, logical_idx):
        header_geom = self._table.horizontalHeader().geometry()
        y_top = header_geom.bottomLeft().y()
        x_left = self._table.horizontalHeader().sectionViewportPosition(logical_idx)
        anchor = QPoint()
        anchor.setX(x_left + 15)
        anchor.setY(y_top)
        anchor = self.mapToGlobal(anchor)
        sec_width = self._table.horizontalHeader().sectionSize(logical_idx)
        hdr_height = self._table.horizontalHeader().size().height()

        if not self._overlay._active:
            self._overlay = HoverPanel()
            self._overlay._active = True
            self._overlay.setFixedSize(sec_width, hdr_height * 3)
            self._overlay.move(anchor.x(), anchor.y())

            lay = QVBoxLayout()
            lay.setContentsMargins(0, 0, 0, 0)
            msg = QLabel("Extra info on Column " + str(logical_idx))
            msg.setWordWrap(True)
            msg.setAlignment(Qt.AlignmentFlag.AlignLeft)
            lay.addWidget(msg)

            self._overlay.setLayout(lay)
            self._overlay.show()
            self._overlay.setFocus()

app = QApplication([])
ui = MainView()
ui.show()
app.exec()

What’s actually wrong and why

This approach demonstrates the concept, but a few critical details can misbehave in real usage. First, relying on manual window flags and focus logic is brittle for something that should behave like a popup: it should close when clicking outside and integrate with the desktop window manager as a transient UI element. Second, computing the popup position must account for the viewport scrolled area. If you use the wrong coordinate mapping, the popup will appear in the wrong place when the header is wider than the view and the view is scrolled horizontally. Finally, creating new floating widgets without scheduling the old ones for deletion can leave behind Qt objects even if the Python reference is overwritten, which risks memory leaks and unpredictable behavior as the app grows.

There are three concrete issues called out by the community that directly apply here. The position for a header section should be taken with sectionViewportPosition, not by APIs that ignore scrolling, otherwise you’ll eventually see misplaced popups. The global position mapping should be done from the header itself instead of the table, to avoid errors when the view is scrolled or embedded. And when you replace an existing popup with a new one, you should explicitly call deleteLater() on the old instance to ensure proper cleanup since it has a parent and won’t be automatically destroyed just by losing its Python reference.

A more idiomatic solution with proper popup semantics

Qt already provides a window type that behaves like a popup and automatically closes when the user clicks outside of it. Using QDialog with the Qt.Popup flag gives predictable modality and cleanup behavior. Combine that with correct coordinate mapping from the header and explicit deletion of previous popups, and you get a robust, scalable foundation for filter UIs.

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QTableView, QDialog, QLabel, QVBoxLayout, QHeaderView
)
from PyQt6.QtCore import Qt, QAbstractTableModel, QPoint

class SheetModel(QAbstractTableModel):
    def __init__(self, items):
        super().__init__()
        self._items = items

    def rowCount(self, parent):
        return len(self._items)

    def columnCount(self, parent):
        return len(self._items[0])

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            return self._items[index.row()][index.column()]

class ColumnToolsPopup(QDialog):
    def __init__(self, col_index: int, parent=None):
        super().__init__(parent)
        self.setWindowFlags(Qt.WindowType.Popup)
        self.setFixedSize(200, 100)

        layout = QVBoxLayout(self)
        info = QLabel(f"Filter tools for column {col_index}")
        layout.addWidget(info)
        self.setLayout(layout)

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

        self._view = QTableView(self)
        self.setCentralWidget(self._view)

        raw = [["Apple", "Red"], ["Banana", "Yellow"], ["Cherry", "Red"], ["Grape", "Purple"]]
        self._sheet = SheetModel(raw)
        self._view.setModel(self._sheet)

        hdr = self._view.horizontalHeader()
        hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        hdr.sectionClicked.connect(self._open_column_popup)

        self._active_popup = None

    def _open_column_popup(self, col):
        hdr = self._view.horizontalHeader()
        x = hdr.sectionViewportPosition(col)
        y = hdr.height()
        global_anchor = hdr.mapToGlobal(QPoint(x, y))

        if self._active_popup and self._active_popup.isVisible():
            self._active_popup.deleteLater()

        self._active_popup = ColumnToolsPopup(col_index=col, parent=self)
        self._active_popup.move(global_anchor)
        self._active_popup.show()

if __name__ == "__main__":
    app = QApplication([])
    win = AppWindow()
    win.resize(600, 400)
    win.show()
    app.exec()

Why this matters

When building interactive tables, small details quickly add up. Correct coordinate mapping avoids visual glitches the moment users scroll horizontally. Using Qt.Popup aligns behavior with user expectations: the popup dismisses on outside clicks and doesn’t steal focus in awkward ways. Calling deleteLater() prevents subtle memory leaks that are easy to overlook when repeatedly creating transient widgets with parents. All of this becomes critical as you evolve from a demo to a long-running application with actual filter logic tied to the model.

Practical next steps

The popup is a good place to add real filter controls. As a starting point, it can contain widgets like QComboBox or QLineEdit for column filtering and QPushButton to apply or clear filters. With the positioning and lifecycle now robust, those additions remain focused on UX and data handling instead of window plumbing.

Takeaways

Use a QDialog with the Qt.Popup flag for header-driven popups, compute the position with sectionViewportPosition and map it to global using the header, then deleteLater() any previous popup before creating a new one. This keeps behavior consistent, avoids subtle bugs when the view is scrolled, and ensures resources are cleaned up—so you can concentrate on building the filtering tools your users actually need.