2025, Dec 27 12:02
Попапы для заголовков QTableView в PyQt6: позиционирование, Qt.Popup и очистка
Как сделать попап фильтров в PyQt6 для заголовков QTableView без багов: правильное позиционирование, Qt.Popup и очистка ресурсов через deleteLater.
Создание взаимодействий «как в электронных таблицах» в PyQt6 часто начинается с простого прототипа: щёлкните по заголовку столбца, покажите небольшой вспомогательный блок с фильтрами или подсказками, а затем закройте его при потере фокуса. Такой быстрый подход работает, но может споткнуться о тонкие детали интерфейса: точное позиционирование при прокрутке, ожидаемое поведение всплывающих окон и управление временем жизни временных виджетов. Вот как сделать всё надёжно.
Чего мы хотим добиться
Нам нужен QTableView, который показывает плавающее всплывающее окно при клике по секции заголовка. Начальная версия опирается на безрамочный QWidget, который закрывается при потере фокуса. Он отображается, перемещается, закрывается — всё неплохо, но реализацию можно сделать более идиоматичной и надёжной.
Базовый пример, демонстрирующий подход
Следующий код демонстрирует идею, используя безрамочное окно, которое появляется под кликнутым заголовком. Имена намеренно простые, логика повторяет минимально работающее решение.
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()
Что на самом деле не так и почему
Этот подход показывает концепцию, но в реальном использовании некоторые важные детали могут вести себя некорректно. Во‑первых, опора на ручные флаги окна и логику фокуса хрупка для того, что должно вести себя как всплывающее окно: оно должно закрываться при клике снаружи и восприниматься диспетчером окон как временный элемент интерфейса. Во‑вторых, при вычислении позиции окна нужно учитывать прокручиваемую область viewport. Если использовать неправильное преобразование координат, попап окажется не на месте, когда заголовок шире области просмотра и вид прокручен по горизонтали. Наконец, создание новых плавающих виджетов без планового удаления старых может оставить Qt‑объекты, даже если Python‑ссылка перезаписана, что чревато утечками памяти и непредсказуемым поведением по мере роста приложения.
Сообщество выделяет три конкретные проблемы, которые напрямую относятся к этому случаю. Позицию секции заголовка следует получать через sectionViewportPosition, а не через API, игнорирующие прокрутку, иначе рано или поздно появятся смещённые попапы. Глобальное преобразование координат нужно делать от самого заголовка, а не от таблицы, чтобы избежать ошибок при прокрутке или встраивании вида. И при замене существующего попапа новым следует явно вызывать deleteLater() у старого экземпляра для корректной очистки, поскольку у него есть родитель и он не будет автоматически уничтожен лишь из‑за потери Python‑ссылки.
Более идиоматичное решение с правильной семантикой всплывающего окна
В Qt уже есть тип окна, который ведёт себя как попап и автоматически закрывается при клике вне его. Использование QDialog с флагом Qt.Popup обеспечивает предсказуемую модальность и корректную очистку. Объедините это с правильным преобразованием координат от заголовка и явным удалением предыдущих окон — и получите надёжную, масштабируемую основу для интерфейсов фильтрации.
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()
Почему это важно
При создании интерактивных таблиц мелочи быстро складываются. Корректное преобразование координат избавляет от визуальных артефактов при горизонтальной прокрутке. Использование Qt.Popup согласует поведение с ожиданиями пользователя: окно закрывается при клике вне и не ведёт себя неловко с фокусом. Вызов deleteLater() предотвращает тонкие утечки памяти, которые легко упустить из виду при многократном создании временных виджетов с родителями. Всё это становится критично, когда вы переходите от демо к долго работающему приложению с реальной фильтрацией, связанной с моделью.
Практические следующие шаги
В попап удобно добавить настоящие элементы фильтрации. Для начала это могут быть виджеты вроде QComboBox или QLineEdit для фильтрации по столбцу и QPushButton для применения или очистки фильтров. Теперь, когда позиционирование и жизненный цикл надёжны, всё внимание можно уделить UX и работе с данными, а не «водопроводу» окон.
Итоги
Используйте QDialog с флагом Qt.Popup для попапов, вызываемых из заголовков; вычисляйте позицию через sectionViewportPosition и переводите её в глобальные координаты от заголовка; перед созданием нового окна вызывайте deleteLater() у предыдущего. Это сохраняет предсказуемое поведение, избегает скрытых багов при прокрутке и обеспечивает очистку ресурсов — чтобы вы могли сосредоточиться на инструментах фильтрации, которые действительно нужны пользователям.