18. (Л) Модель–представлення (Model/View). Основи MVC. Представлення табличних даних¶
Зміст лекції¶
- Проблема: дані живуть у віджеті
- MVC: звідки взявся поділ
- Model/View у Qt: контролер зник, з'явився делегат
- Карта класів
- Перший застосунок: одна модель — два представлення
QModelIndex: адреса комірки- Ролі: одна комірка — багато значень
- Готові моделі:
QStringListModelіQStandardItemModel - Зручні віджети проти представлень
- Власна модель:
QAbstractTableModel - Редагування:
flags→ редактор →setData - Додавання й видалення рядків
- Повна перебудова:
beginResetModel - Сортування й фільтрація:
QSortFilterProxyModel - Виділення:
QItemSelectionModel - Налаштування
QTableView - Делегат: власний редактор і власне малювання
- Модель поверх спільного стану
- Збірка: застосунок «Notes» з таблицею
- Коли Model/View не потрібен
- Типові помилки
- Підсумок
Проблема: дані живуть у віджеті¶
У лекції 3 ми показували списки через QListWidget, а таблиці — через QTableWidget. Виглядає це просто:
table = QTableWidget(0, 3)
table.setHorizontalHeaderLabels(["Name", "Position", "Salary"])
for employee in employees:
row = table.rowCount()
table.insertRow(row)
table.setItem(row, 0, QTableWidgetItem(employee.name))
table.setItem(row, 1, QTableWidgetItem(employee.position))
table.setItem(row, 2, QTableWidgetItem(str(employee.salary)))
Поки записів десяток, а вікно одне, все гаразд. Проблеми починаються далі.
Дані існують у двох екземплярах. Список employees — це одна правда, комірки QTableWidgetItem — друга. Користувач відредагував комірку: змінився рядок у віджеті, а об'єкт Employee лишився старим. Тепер треба вручну писати «синхронізатор» в обидва боки, і кожна нова колонка додає до нього ще одну гілку.
Тип втрачається. У комірці лежить текст. Зарплата 9000 перетворюється на рядок "9000", і сортування за цією колонкою ставить "9000" після "51000", бо "9" більше за "5". Щоб цього не сталося, доводиться вигадувати приховані колонки чи доповнювати числа нулями.
Друге вікно означає третю копію. Після лекції 16 ми знаємо: вікон може бути кілька. Кожен QTableWidget наповнюється окремо, і синхронізувати їх доводиться руками.
Великі дані не влазять. Десять тисяч рядків — це десять тисяч QTableWidgetItem, створених наперед, навіть якщо користувач побачить п'ятнадцять із них.
Порівняймо з тим, чого ми домоглися в лекції 14: дані живуть у AppState, а вікна лише перемальовуються з нього. Але render() там перебудовував список цілком:
def render(self):
self.list_widget.clear()
for note in self.state.notes():
self.list_widget.addItem(note.display_title())
Для двадцяти нотаток це нормально. Для тисячі — помітне підморгування списку, втрачена позиція прокрутки й втрачене виділення при кожній зміні одного символу.
Потрібен механізм, у якому віджет читає дані з об'єкта-джерела на вимогу, а не отримує їхню копію. Саме це й робить Model/View.
MVC: звідки взявся поділ¶
MVC (Model–View–Controller) — архітектурний шаблон, придуманий у кінці 1970-х у мові Smalltalk-80. Ідея: розділити застосунок на три частини з різними причинами для змін.
graph LR
U["Користувач"] --> C["Controller<br/>обробляє ввід"]
C -->|"змінює"| M["Model<br/>дані та правила"]
M -->|"повідомляє про зміни"| V["View<br/>показує дані"]
V --> U
| Частина | Відповідає за | Не знає про |
|---|---|---|
| Model | дані, правила їх зміни, повідомлення про зміни | як саме їх показують |
| View | показ даних на екрані | звідки дані взялись |
| Controller | обробку дій користувача, виклик методів моделі | як дані намальовані |
Головна цінність — напрямок залежностей: модель не залежить ні від чого, представлення залежить від моделі, і ніколи навпаки. Логіку можна протестувати без інтерфейсу, а інтерфейс — замінити, не чіпаючи логіку.
Це та сама думка, з якої починалася лекція 14: AppState — це модель рівня застосунку, вікна — представлення, слоти — контролер. MVC у цій лекції — те саме правило, застосоване на менший масштаб: до одного списку даних та одного віджета.
MVC — це сімейство, а не один шаблон
У різних середовищах межі проводять по-різному: MVP (Model–View–Presenter), MVVM (Model–View–ViewModel, звідки в Qt Quick прив'язки даних), Flux/Redux у вебі. Спільне в усіх — дані окремо, показ окремо. Різниця лише в тому, хто саме зв'язує їх докупи.
Model/View у Qt: контролер зник, з'явився делегат¶
Qt реалізує спрощений MVC, який у документації називають Model/View. Відмінності дві.
Перша: контролер злився з представленням. Клацання по рядку, прокрутка, виділення, натискання F2 — усе це обробляє сам віджет-представлення. Окремого класу-контролера немає.
Друга: з'явився делегат. Малювання окремої комірки й створення редактора для неї винесені в четвертий об'єкт — делегат. Це дозволяє змінити вигляд однієї колонки, не переписуючи ані модель, ані представлення.
graph TD
D["Дані застосунку<br/>список, БД, файл"] --> M["Model<br/>QAbstractItemModel"]
M -->|"data(index, role)"| V["View<br/>QTableView / QListView"]
V -->|"setData(index, value)"| M
V --> DEL["Delegate<br/>QStyledItemDelegate<br/>малює комірку, створює редактор"]
V --> SEL["SelectionModel<br/>QItemSelectionModel<br/>що вибрано"]
M -->|"сигнали про зміни"| V
Ключове правило всієї схеми:
Представлення не зберігає даних
QTableView не має власного списку рядків. Щоразу, коли треба намалювати комірку, він питає модель: data(index, role). Тому видимі 15 рядків із мільйона коштують 15 викликів, а не мільйон об'єктів у пам'яті.
Хто за що відповідає:
| Об'єкт | Відповідає | Типовий клас |
|---|---|---|
| Модель | скільки рядків і колонок, що в комірці, чи можна редагувати, коли дані змінились | QAbstractTableModel |
| Представлення | розташування, прокрутка, заголовки, реакція на клавіші й миш | QTableView |
| Делегат | вигляд однієї комірки та її редактор | QStyledItemDelegate |
| Модель виділення | які комірки вибрані та де курсор | QItemSelectionModel |
| Проксі | сортування, фільтрація, перестановка колонок | QSortFilterProxyModel |
Карта класів¶
Перед першим прикладом варто побачити всю сім'ю класів одразу — далі буде зрозуміліше, звідки що береться.
Абстрактні моделі (від них успадковуються):
| Клас | Коли брати |
|---|---|
QAbstractListModel |
одна колонка: список рядків, імен, файлів |
QAbstractTableModel |
таблиця: рядки й колонки, без вкладеності |
QAbstractItemModel |
дерево з довільною вкладеністю (найскладніший варіант) |
Готові моделі (використовуються як є):
| Клас | Що містить |
|---|---|
QStringListModel |
список рядків, редагований |
QStandardItemModel |
універсальне сховище комірок QStandardItem (список, таблиця або дерево) |
QFileSystemModel |
вміст каталогів файлової системи |
QSqlTableModel, QSqlQueryModel |
таблиця або результат запиту з бази даних |
Проксі-моделі (стоять між моделлю й представленням):
| Клас | Що робить |
|---|---|
QSortFilterProxyModel |
сортує та фільтрує рядки джерела |
QIdentityProxyModel |
нічого не змінює; основа для власного проксі |
Представлення:
| Клас | Показує |
|---|---|
QListView |
одну колонку списком або сіткою значків |
QTableView |
таблицю з заголовками рядків і колонок |
QTreeView |
дерево з розгортанням |
QColumnView |
дерево «колонками», як у Finder |
Зручні віджети (модель усередині, назовні не видно):
QListWidget, QTableWidget, QTreeWidget — це відповідні представлення з уже вбудованим QStandardItemModel. Про них — окремий розділ.
Перший застосунок: одна модель — два представлення¶
Найкоротший спосіб відчути різницю — показати один об'єкт-модель у двох віджетах одразу.
import sys
from PySide6.QtCore import QStringListModel
from PySide6.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
class Window(QWidget):
"""Дві незалежні панелі показують один і той самий об'єкт-модель."""
def __init__(self):
super().__init__()
self.setWindowTitle("One model - two views")
# Дані живуть тут, а не у віджетах.
self.model = QStringListModel(["Alpha", "Beta", "Gamma"])
left_view = QListView()
left_view.setModel(self.model)
right_view = QListView()
right_view.setModel(self.model)
add_button = QPushButton("Add item")
add_button.clicked.connect(self.add_item)
views = QHBoxLayout()
views.addWidget(left_view)
views.addWidget(right_view)
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Edit an item in any view - both update."))
layout.addLayout(views)
layout.addWidget(add_button)
def add_item(self):
row = self.model.rowCount()
# Модель сама повідомить обидва представлення про новий рядок.
self.model.insertRow(row)
self.model.setData(self.model.index(row, 0), f"Item {row + 1}")
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(480, 260)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Запустіть і зробіть дві речі:
- Двічі клацніть по елементу в лівому списку, змініть текст, натисніть
Enter— правий список змінюється сам. Жодного рядка коду синхронізації в застосунку немає. - Натисніть
Add item— новий рядок з'являється в обох списках.
Порівняйте з QListWidget: там на дві панелі потрібні дві копії даних і код, який тримає їх однаковими. Тут копій немає взагалі — є один об'єкт даних і два вікна в нього.
setModel() не передає віджету дані; він передає посилання на джерело. Далі віджет питає це джерело щоразу, коли малює рядок.
QModelIndex: адреса комірки¶
Уся взаємодія моделі й представлення відбувається через QModelIndex — легкий об'єкт, який відповідає на питання «яка це комірка».
| Метод | Повертає |
|---|---|
index.row() |
номер рядка |
index.column() |
номер колонки |
index.parent() |
батьківський індекс (для таблиці — завжди недійсний) |
index.model() |
модель, якій індекс належить |
index.data(role) |
значення для ролі — короткий запис index.model().data(index, role) |
index.isValid() |
чи вказує індекс на існуючу комірку |
Індекси не створюють вручну: їх видає модель.
index = model.index(2, 1) # рядок 2, колонка 1
top_level = model.index(0, 0) # у таблиці батько завжди кореневий
empty = QModelIndex() # недійсний індекс = «корінь»
Недійсний QModelIndex() має особливий сенс: це корінь моделі. Саме тому в методах моделі параметр parent за замовчуванням дорівнює QModelIndex(), і саме тому в таблиці перевірка parent.isValid() означає «у мене питають про дітей комірки, а їх у таблиці не буває».
Не зберігайте QModelIndex у полі об'єкта
Індекс дійсний лише «зараз». Після вставки, видалення, сортування чи скидання моделі збережений індекс вказує не туди або стає недійсним — і код тихо працює з чужим рядком. Зберігати треба ідентифікатор запису (note.id), а індекс отримувати заново. Якщо індекс справді потрібно пережити зміну — існує QPersistentModelIndex, який модель оновлює автоматично; він дорожчий, тому його беруть лише за потреби.
Ролі: одна комірка — багато значень¶
Комірка — це не одне значення, а набір значень, кожне зі своїм призначенням. Призначення позначають роллю (Qt.ItemDataRole).
| Роль | Що означає | Тип значення |
|---|---|---|
DisplayRole |
текст, який видно | str (типово) |
EditRole |
значення для редактора й для сортування | будь-який |
ToolTipRole |
підказка під курсором | str |
StatusTipRole |
текст у рядку стану | str |
DecorationRole |
значок ліворуч від тексту | QIcon, QPixmap, QColor |
TextAlignmentRole |
вирівнювання | Qt.AlignmentFlag |
FontRole |
шрифт комірки | QFont |
ForegroundRole / BackgroundRole |
колір тексту / тла | QBrush, QColor |
CheckStateRole |
стан галочки | Qt.CheckState |
SizeHintRole |
бажаний розмір комірки | QSize |
UserRole |
власні дані застосунку (UserRole, UserRole + 1, …) |
будь-який |
Чому DisplayRole і EditRole розділені: показувати зручно "42 000 UAH", а редагувати й сортувати треба число 42000. Одна комірка віддає обидва значення — кожне за своєю роллю.
Типовий скелет методу data():
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
if role == Qt.ItemDataRole.DisplayRole:
return "text for the screen"
if role == Qt.ItemDataRole.EditRole:
return 42
return None # усі інші ролі модель не обслуговує
Невідома роль — поверніть None
Представлення питає комірку про десяток ролей поспіль. Якщо модель на будь-яку роль повертає рядок, Qt спробує використати його як шрифт, колір чи розмір — і комірка намалюється дивно або застосунок впаде. Правило: явна перевірка ролі, а наприкінці — return None.
Роль UserRole — стандартний спосіб покласти в комірку те, чого користувач не бачить: наприклад, id запису.
Готові моделі: QStringListModel і QStandardItemModel¶
Не для кожної задачі треба писати власний клас. Дві готові моделі покривають більшість дрібних випадків.
QStringListModel — список рядків. Одна колонка, редагування «з коробки», методи stringList() / setStringList(). Саме він був у першому прикладі.
QStandardItemModel — універсальне сховище комірок. Кожна комірка — окремий об'єкт QStandardItem, у якому ролі виставляють методами:
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import QApplication, QTableView
ROWS = [
("Kyiv", 2952, True),
("Lviv", 717, False),
("Odesa", 1010, True),
]
def build_model():
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["City", "Population, k", "Capital"])
for name, population, is_capital in ROWS:
name_item = QStandardItem(name)
name_item.setToolTip(f"City of {name}")
population_item = QStandardItem(str(population))
population_item.setTextAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
if population > 1000:
population_item.setForeground(QBrush(QColor("#b00020")))
capital_item = QStandardItem()
capital_item.setCheckable(True)
capital_item.setCheckState(
Qt.CheckState.Checked if is_capital else Qt.CheckState.Unchecked
)
model.appendRow([name_item, population_item, capital_item])
return model
def main():
app = QApplication(sys.argv)
view = QTableView()
view.setWindowTitle("QStandardItemModel")
view.setModel(build_model())
view.resizeColumnsToContents()
view.resize(420, 200)
view.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
QStandardItemModel зручний, але має ту саму ваду, що й QTableWidget: дані в ньому лежать окремою копією. Він добре підходить, коли таблиця — сама собі джерело (налаштування, довідник, разовий звіт), і погано — коли дані вже живуть у вашому AppState.
| Модель | Брати, коли |
|---|---|
QStringListModel |
простий список рядків, який нікуди більше не використовується |
QStandardItemModel |
невелика таблиця чи дерево, дані якої більше ніде не потрібні |
Власна QAbstractTableModel |
дані вже існують у застосунку (список об'єктів, AppState, БД) |
Зручні віджети проти представлень¶
QTableWidget — це QTableView із уже вставленим QStandardItemModel. Звідси всі його властивості.
| Ознака | QTableWidget |
QTableView + модель |
|---|---|---|
| Де дані | всередині віджета | у вашій моделі |
| Копія даних | так | ні |
| Кілька вікон з тими самими даними | кожне наповнюємо руками | усі беруть одну модель |
| Сортування за числом | через хитрощі | через EditRole |
| Фільтрація | ховати рядки вручну | QSortFilterProxyModel |
| Тисячі рядків | усі об'єкти створюються наперед | створюється лише видиме |
| Код на просту таблицю | коротший | довший |
setModel() |
недоступний | основний спосіб роботи |
Останній рядок часто застає зненацька: QTableWidget.setModel() викликати не можна — Qt виведе попередження й нічого не зробить. Якщо ви дійшли до власної моделі, віджет треба міняти на QTableView.
Не варто вважати зручні віджети «поганими»: для статичного списку з п'яти пунктів QListWidget — правильний вибір. Але щойно ті самі дані потрібні ще десь, час переходити на Model/View.
Власна модель: QAbstractTableModel¶
Це основний клас лекції. Він перетворює список ваших об'єктів на таблицю, не копіюючи їх.
Обов'язковий мінімум — три методи, і майже завжди четвертий:
| Метод | Питання представлення | Обов'язковий |
|---|---|---|
rowCount(parent) |
скільки рядків? | так |
columnCount(parent) |
скільки колонок? | так |
data(index, role) |
що в цій комірці для цієї ролі? | так |
headerData(section, orientation, role) |
що написати в заголовку? | ні, але без нього заголовки — числа |
Модель тільки для читання виглядає так:
import sys
from dataclasses import dataclass
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtWidgets import QApplication, QTableView
@dataclass
class Employee:
name: str
position: str
salary: int
EMPLOYEES = [
Employee("Ivan Petrenko", "Developer", 42000),
Employee("Olena Koval", "Designer", 38000),
Employee("Petro Shevchuk", "Manager", 51000),
]
class EmployeeTableModel(QAbstractTableModel):
"""Табличне представлення списку об'єктів. Тільки читання."""
COLUMNS = ["Name", "Position", "Salary"]
def __init__(self, employees, parent=None):
super().__init__(parent)
self._employees = employees
# ---------------------------------------------------------- розмір
def rowCount(self, parent=QModelIndex()):
# У таблиці рядки має лише корінь: у комірок дітей немає.
if parent.isValid():
return 0
return len(self._employees)
def columnCount(self, parent=QModelIndex()):
if parent.isValid():
return 0
return len(self.COLUMNS)
# ----------------------------------------------------------- дані
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
employee = self._employees[index.row()]
column = index.column()
if role == Qt.ItemDataRole.DisplayRole:
if column == 0:
return employee.name
if column == 1:
return employee.position
if column == 2:
return f"{employee.salary:,}".replace(",", " ")
if role == Qt.ItemDataRole.TextAlignmentRole and column == 2:
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
if role == Qt.ItemDataRole.ToolTipRole:
return f"{employee.name} - {employee.position}"
# Роль, яку модель не обслуговує: явно нічого.
return None
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return self.COLUMNS[section]
return section + 1
def main():
app = QApplication(sys.argv)
model = EmployeeTableModel(EMPLOYEES)
view = QTableView()
view.setWindowTitle("QAbstractTableModel")
view.setModel(model)
view.resizeColumnsToContents()
view.resize(480, 200)
view.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Що тут важливо помітити.
Модель не володіє даними. У конструктор передали той самий список EMPLOYEES, який живе в застосунку. Копії немає — отже, немає й розсинхронізації.
Перевірка parent.isValid(). У таблиці дітей у комірок немає, тому на питання «скільки рядків усередині комірки (1, 2)?» модель зобов'язана відповісти 0. Без цієї перевірки модель, показана в QTreeView, «розгортатиметься» нескінченно, а деякі проксі почнуть рахувати рядки вдвічі.
headerData для вертикального заголовка повертає section + 1 — нумерацію рядків з одиниці. Повернете None — побачите порожні комірки заголовка.
Аргументи за замовчуванням обов'язкові. rowCount(self, parent=QModelIndex()) — Qt викликає метод і з аргументом, і без нього; ваш код може викликати model.rowCount().
data() і rowCount() викликаються дуже часто
Для кожної видимої комірки — щонайменше десяток викликів data() при кожній перемальовці, і ще стільки ж під час прокрутки. Тому всередині цих методів не місце для запитів до бази, читання файлів чи важких обчислень: усе, що дорого, має бути обчислене заздалегідь і лежати в об'єкті рядка.
Редагування: flags → редактор → setData¶
Модель із попереднього розділу — тільки для читання: подвійне клацання нічого не відкриває. Щоб таблицю можна було редагувати, потрібні два методи.
Ось повний ланцюжок того, що відбувається під час редагування:
graph TD
A["Подвійне клацання по комірці"] --> B["View: flags(index)<br/>чи є ItemIsEditable?"]
B -->|"так"| C["Delegate: createEditor<br/>створює QLineEdit"]
C --> D["Delegate: setEditorData<br/>читає data(index, EditRole)"]
D --> E["Користувач вводить значення<br/>і натискає Enter"]
E --> F["Delegate: setModelData<br/>викликає model.setData"]
F --> G["Model: setData<br/>перевіряє й змінює дані"]
G --> H["Model: emit dataChanged"]
H --> I["View перемальовує комірку"]
flags() повідомляє, що з коміркою дозволено робити:
| Прапорець | Дозволяє |
|---|---|
ItemIsEnabled |
комірка активна (без нього — сіра й недоступна) |
ItemIsSelectable |
комірку можна вибрати |
ItemIsEditable |
комірку можна редагувати |
ItemIsUserCheckable |
у комірці є галочка, яку можна перемикати |
ItemIsDragEnabled / ItemIsDropEnabled |
перетягування |
Прапорці можна видавати вибірково — наприклад, редагувати дозволити лише першу колонку:
def flags(self, index):
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
if index.column() == 0:
flags |= Qt.ItemFlag.ItemIsEditable
return flags
setData() приймає нове значення. У нього чотири обов'язки:
- перевірити роль і індекс;
- перевірити саме значення (валідація живе тут);
- записати зміну в дані;
- повідомити про зміну сигналом
dataChangedі повернутиTrue.
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if not index.isValid() or role != Qt.ItemDataRole.EditRole:
return False
try:
salary = int(value)
except (TypeError, ValueError):
return False # значення не прийняли
self._employees[index.row()].salary = salary
self.dataChanged.emit(index, index, [role])
return True
False означає «не прийняв», а не «сталася помилка»
Якщо setData() повертає False, представлення вважає, що значення відхилено: комірка лишається зі старим вмістом. Це штатний спосіб відмовити в неправильному вводі. А от повернути True, не надіславши dataChanged, — типова помилка: дані змінились, а на екрані старе значення.
Сигнал dataChanged приймає діапазон комірок: лівий верхній і правий нижній індекси. Якщо змінилась одна комірка — обидва індекси однакові. Якщо змінився весь рядок:
left = self.index(row, 0)
right = self.index(row, self.columnCount() - 1)
self.dataChanged.emit(left, right)
Третій аргумент — список ролей, що змінились. Він необов'язковий, але корисний: представлення й проксі можуть на ньому заощадити роботу.
Повний застосунок із редагуванням, вставкою й видаленням (методи вставки розберемо в наступному розділі):
import sys
from dataclasses import dataclass
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QHBoxLayout,
QHeaderView,
QPushButton,
QTableView,
QVBoxLayout,
QWidget,
)
@dataclass
class Employee:
name: str
position: str
salary: int
class EmployeeTableModel(QAbstractTableModel):
"""Редагована модель зі вставкою та видаленням рядків."""
COLUMNS = ["Name", "Position", "Salary"]
def __init__(self, employees, parent=None):
super().__init__(parent)
self._employees = list(employees)
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self._employees)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.COLUMNS)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
employee = self._employees[index.row()]
column = index.column()
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
if column == 0:
return employee.name
if column == 1:
return employee.position
if column == 2:
return employee.salary
if role == Qt.ItemDataRole.TextAlignmentRole and column == 2:
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
return None
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return self.COLUMNS[section]
return section + 1
# -------------------------------------------------------- редагування
def flags(self, index):
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
return (
Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
| Qt.ItemFlag.ItemIsEditable
)
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if not index.isValid() or role != Qt.ItemDataRole.EditRole:
return False
employee = self._employees[index.row()]
column = index.column()
if column == 0:
text = str(value).strip()
if not text:
return False # порожнє ім'я не приймаємо
employee.name = text
elif column == 1:
employee.position = str(value).strip()
elif column == 2:
try:
salary = int(value)
except (TypeError, ValueError):
return False # не число - значення відхилено
if salary < 0:
return False
employee.salary = salary
else:
return False
# Без цього сигналу представлення не дізнається про зміну.
self.dataChanged.emit(index, index, [role, Qt.ItemDataRole.DisplayRole])
return True
# ------------------------------------------------- вставка й видалення
def add_employee(self, employee):
row = len(self._employees)
self.beginInsertRows(QModelIndex(), row, row)
self._employees.append(employee)
self.endInsertRows()
return row
def remove_row(self, row):
if not 0 <= row < len(self._employees):
return False
self.beginRemoveRows(QModelIndex(), row, row)
del self._employees[row]
self.endRemoveRows()
return True
def employees(self):
return tuple(self._employees)
class Window(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Editable table model")
self.model = EmployeeTableModel(
[
Employee("Ivan Petrenko", "Developer", 42000),
Employee("Olena Koval", "Designer", 38000),
]
)
self.view = QTableView()
self.view.setModel(self.model)
self.view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.view.verticalHeader().setVisible(False)
add_button = QPushButton("Add row")
add_button.clicked.connect(self.add_row)
remove_button = QPushButton("Remove selected")
remove_button.clicked.connect(self.remove_selected)
buttons = QHBoxLayout()
buttons.addWidget(add_button)
buttons.addWidget(remove_button)
buttons.addStretch()
layout = QVBoxLayout(self)
layout.addWidget(self.view)
layout.addLayout(buttons)
def add_row(self):
row = self.model.add_employee(Employee("New employee", "Trainee", 0))
index = self.model.index(row, 0)
self.view.setCurrentIndex(index)
self.view.edit(index) # одразу відкриваємо редактор
def remove_selected(self):
index = self.view.currentIndex()
if index.isValid():
self.model.remove_row(index.row())
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(560, 260)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Спробуйте ввести в колонку Salary текст abc — редактор не закриється, бо setData() повернув False. Введіть -100 — те саме. Введіть 50000 — значення з'явиться в таблиці.
Додавання й видалення рядків¶
Найпоширеніша причина падінь у Model/View — зміна списку без повідомлення моделі:
Представлення досі вважає, що рядків було три; смуга прокрутки не змінилась; а щойно віджет спробує намалювати новий рядок, поведінка стане непередбачуваною — від «нічого не з'явилось» до IndexError під час прокрутки.
Модель зобов'язана обгорнути кожну структурну зміну в пару викликів:
| Дія | Пара викликів |
|---|---|
| вставка рядків | beginInsertRows(parent, first, last) … endInsertRows() |
| видалення рядків | beginRemoveRows(parent, first, last) … endRemoveRows() |
| вставка колонок | beginInsertColumns(...) … endInsertColumns() |
| видалення колонок | beginRemoveColumns(...) … endRemoveColumns() |
| переміщення рядків | beginMoveRows(...) … endMoveRows() |
| усе змінилось | beginResetModel() … endResetModel() |
Три деталі, на яких спотикаються:
first і last включно. Один рядок на позиції row — це beginInsertRows(QModelIndex(), row, row), а не row, row + 1.
Номери рядків — після вставки й до видалення. Для вставки вказують позиції, які нові рядки займуть; для видалення — позиції, які рядки зараз займають.
Дані змінюють між begin і end. Порядок «спершу begin, потім зміна списку, потім end» — не стилістична забаганка: між цими викликами Qt зберігає й оновлює виділення та відкриті редактори.
def add_employee(self, employee):
row = len(self._employees)
self.beginInsertRows(QModelIndex(), row, row)
self._employees.append(employee)
self.endInsertRows()
Вставляти можна не тільки в кінець
beginInsertRows(QModelIndex(), 0, 0) + self._employees.insert(0, employee) додасть рядок на початок. Головне, щоб номери в begin... збігалися з тим, що ви реально робите зі списком.
Окремо існують віртуальні методи insertRows() / removeRows(). Їх перевизначають, коли рядки має вставляти хтось ззовні — наприклад, редагована QStringListModel або механізм drag-and-drop. Для власного застосунку зазвичай зручніше мати осмислені методи (add_employee, remove_row), які самі викликають пару begin/end.
Повна перебудова: beginResetModel¶
Коли змінилось не кілька рядків, а весь набір даних — завантажили інший файл, перечитали таблицю з БД, застосували нове сортування в джерелі — використовують скидання:
def reload(self, employees):
self.beginResetModel()
self._employees = list(employees)
self.endResetModel()
Це найпростіший і найгрубіший інструмент.
dataChanged / begin*Rows |
beginResetModel |
|
|---|---|---|
| Що оновлюється | точково | усе представлення |
| Виділення | зберігається | втрачається |
| Позиція прокрутки | зберігається | скидається |
| Відкритий редактор | лишається | закривається |
| Складність коду | треба знати, що саме змінилось | нічого знати не треба |
Тому правило таке: скидання — для заміни всього набору даних, точкові сигнали — для звичайної роботи. Якщо кожне натискання клавіші в полі пошуку скидає модель, користувач втрачатиме виділення й позицію в списку на кожен символ.
Сортування й фільтрація: QSortFilterProxyModel¶
Сортувати й фільтрувати всередині власної моделі не потрібно. Для цього між моделлю й представленням ставлять проксі:
graph LR
S["Джерело<br/>EmployeeTableModel"] --> P["Проксі<br/>QSortFilterProxyModel<br/>сортує й фільтрує"]
P --> V["Представлення<br/>QTableView"]
Проксі — теж модель: у нього є рядки, колонки й data(). Різниця в тому, що власних даних він не має, а лише переставляє й приховує рядки джерела.
Мінімальне під'єднання:
proxy = QSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterKeyColumn(-1) # шукати в усіх колонках
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
proxy.setSortRole(Qt.ItemDataRole.EditRole) # сортувати за EditRole
view.setModel(proxy) # представлення бачить проксі
view.setSortingEnabled(True) # клацання по заголовку сортує
search_edit.textChanged.connect(proxy.setFilterFixedString)
Корисні налаштування:
| Метод | Що робить |
|---|---|
setSourceModel(model) |
вказує джерело |
setFilterFixedString(text) |
лишає рядки, що містять підрядок |
setFilterRegularExpression(pattern) |
фільтр за регулярним виразом |
setFilterKeyColumn(n) |
колонка для фільтра; -1 — усі колонки |
setFilterRole(role) |
роль, за якою фільтрувати (типово DisplayRole) |
setSortRole(role) |
роль, за якою сортувати |
sort(column, order) |
відсортувати з коду |
setDynamicSortFilter(True) |
перефільтровувати автоматично після змін у джерелі (типово увімкнено) |
invalidateFilter() |
перепитати власний фільтр після зміни його критеріїв |
setSortRole(EditRole) — головна причина, чому число сортується як число
Типово проксі сортує за DisplayRole, тобто за текстом: "9000" виявиться більшим за "51000". Якщо data() повертає для EditRole справжнє число, а проксі налаштований на EditRole, сортування стає правильним без жодних хитрощів.
Коли стандартного фільтра замало, успадковують проксі й перевизначають filterAcceptsRow() (які рядки лишити) та за потреби lessThan() (як порівнювати).
Найважливіше правило роботи з проксі:
Індекс проксі — не індекс джерела
view.currentIndex().row() — це номер рядка на екрані. Після сортування чи фільтрації він не збігається з номером у вашому списку. Переклад роблять явно: proxy.mapToSource(proxy_index) — у джерело, proxy.mapFromSource(source_index) — назад. Найнадійніше взагалі не працювати з номерами рядків, а брати з моделі id запису.
Повний приклад: пошук за текстом, фільтр за мінімальною зарплатою, сортування клацанням по заголовку і переклад індексів під час виділення.
import sys
from dataclasses import dataclass
from PySide6.QtCore import (
QAbstractTableModel,
QModelIndex,
QSortFilterProxyModel,
Qt,
)
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QSpinBox,
QTableView,
QVBoxLayout,
QWidget,
)
@dataclass
class Employee:
name: str
position: str
salary: int
EMPLOYEES = [
Employee("Ivan Petrenko", "Developer", 42000),
Employee("Olena Koval", "Designer", 38000),
Employee("Petro Shevchuk", "Manager", 51000),
Employee("Maria Bondar", "Developer", 9000),
Employee("Andrii Tkachuk", "QA engineer", 33000),
]
class EmployeeTableModel(QAbstractTableModel):
COLUMNS = ["Name", "Position", "Salary"]
def __init__(self, employees, parent=None):
super().__init__(parent)
self._employees = list(employees)
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self._employees)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.COLUMNS)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
employee = self._employees[index.row()]
column = index.column()
if role == Qt.ItemDataRole.DisplayRole:
if column == 0:
return employee.name
if column == 1:
return employee.position
if column == 2:
return f"{employee.salary} UAH"
if role == Qt.ItemDataRole.EditRole:
# Сортування читатиме саме цю роль: тут число, а не текст.
if column == 0:
return employee.name
if column == 1:
return employee.position
if column == 2:
return employee.salary
if role == Qt.ItemDataRole.TextAlignmentRole and column == 2:
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
return None
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return self.COLUMNS[section]
return section + 1
def employee_at(self, row):
return self._employees[row]
class EmployeeFilterProxy(QSortFilterProxyModel):
"""Два незалежні критерії: підрядок у тексті та мінімальна зарплата."""
def __init__(self, parent=None):
super().__init__(parent)
self._text = ""
self._min_salary = 0
def set_search_text(self, text):
self._text = text.strip().lower()
self.invalidateFilter() # перепитати filterAcceptsRow для всіх рядків
def set_min_salary(self, value):
self._min_salary = value
self.invalidateFilter()
def filterAcceptsRow(self, source_row, source_parent):
model = self.sourceModel()
salary = model.index(source_row, 2, source_parent).data(
Qt.ItemDataRole.EditRole
)
if salary < self._min_salary:
return False
if not self._text:
return True
name = model.index(source_row, 0, source_parent).data() or ""
position = model.index(source_row, 1, source_parent).data() or ""
return self._text in name.lower() or self._text in position.lower()
class Window(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Sorting and filtering")
self.model = EmployeeTableModel(EMPLOYEES)
self.proxy = EmployeeFilterProxy()
self.proxy.setSourceModel(self.model)
self.proxy.setSortRole(Qt.ItemDataRole.EditRole)
self.view = QTableView()
self.view.setModel(self.proxy) # представлення бачить лише проксі
self.view.setSortingEnabled(True)
self.view.sortByColumn(0, Qt.SortOrder.AscendingOrder)
self.view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.view.verticalHeader().setVisible(False)
self.view.selectionModel().currentChanged.connect(self.on_current_changed)
self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText("Search by name or position")
self.search_edit.textChanged.connect(self.proxy.set_search_text)
self.salary_spin = QSpinBox()
self.salary_spin.setRange(0, 100000)
self.salary_spin.setSingleStep(1000)
self.salary_spin.valueChanged.connect(self.proxy.set_min_salary)
self.status_label = QLabel("Nothing selected")
filters = QHBoxLayout()
filters.addWidget(self.search_edit)
filters.addWidget(QLabel("Min salary:"))
filters.addWidget(self.salary_spin)
layout = QVBoxLayout(self)
layout.addLayout(filters)
layout.addWidget(self.view)
layout.addWidget(self.status_label)
def on_current_changed(self, current, previous):
if not current.isValid():
self.status_label.setText("Nothing selected")
return
# Індекс належить проксі; дані живуть у джерелі.
source_index = self.proxy.mapToSource(current)
employee = self.model.employee_at(source_index.row())
self.status_label.setText(
f"View row {current.row()} -> source row {source_index.row()}: {employee.name}"
)
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(620, 320)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Поклацайте по заголовку Salary: сортування правильне саме тому, що EditRole віддає число. Введіть dev у пошук — лишаться два розробники. Виберіть рядок і подивіться на підпис унизу: номер рядка на екрані й номер у джерелі зазвичай різні.
Проксі можна ставити ланцюжком: джерело → фільтр за категорією → фільтр за текстом → представлення. Кожен наступний вважає попередній своїм джерелом.
Виділення: QItemSelectionModel¶
Виділення — теж окремий об'єкт. Представлення створює його автоматично, а дістати можна методом view.selectionModel().
Розрізняють два поняття:
| Поняття | Що це | Скільки |
|---|---|---|
поточний індекс (currentIndex) |
комірка з фокусом клавіатури, обведена рамкою | завжди одна |
виділення (selection) |
набір вибраних комірок чи рядків | скільки завгодно |
Зазвичай вони збігаються, але не завжди: Ctrl + клацання переносить курсор, не змінюючи виділення.
Основні сигнали:
| Сигнал | Коли надходить | Аргументи |
|---|---|---|
currentChanged |
змінилась комірка з фокусом | current, previous |
selectionChanged |
змінився набір вибраного | selected, deselected |
currentRowChanged |
фокус перейшов на інший рядок | current, previous |
Для «однорядкового» списку зручний саме currentChanged:
Режими виділення налаштовують на представленні:
view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
| Метод | Значення | Ефект |
|---|---|---|
setSelectionBehavior |
SelectItems / SelectRows / SelectColumns |
що саме виділяється |
setSelectionMode |
SingleSelection / ExtendedSelection / MultiSelection / NoSelection |
скільки одночасно |
Прочитати виділені рядки:
indexes = view.selectionModel().selectedRows() # по одному індексу на рядок
rows = [index.row() for index in indexes]
І головна практична деталь, якщо виділення синхронізується зі спільним станом: петля. Клацання змінює виділення → слот кличе state.select(id) → стан надсилає selection_changed → вікно ставить виділення → представлення знову надсилає currentChanged… Розривають її тим самим прапорцем, що й у лекції 16:
def _restore_selection(self):
self._syncing = True
try:
... # програмна зміна виділення
finally:
self._syncing = False
def _on_current_changed(self, current, previous):
if self._syncing:
return
...
Налаштування QTableView¶
Таблиця «з коробки» виглядає посередньо: колонки однакової ширини, зайвий вертикальний заголовок, редагування вмикається випадковим клацанням. Кілька рядків це виправляють.
| Виклик | Ефект |
|---|---|
view.horizontalHeader().setSectionResizeMode(col, mode) |
як змінюється ширина колонки |
view.horizontalHeader().setStretchLastSection(True) |
остання колонка займає залишок |
view.verticalHeader().setVisible(False) |
сховати нумерацію рядків |
view.setAlternatingRowColors(True) |
смугасті рядки |
view.setShowGrid(False) |
прибрати сітку |
view.setSortingEnabled(True) |
сортування клацанням по заголовку |
view.setEditTriggers(...) |
чим відкривається редактор |
view.setColumnHidden(col, True) |
сховати колонку (наприклад, службовий id) |
view.resizeColumnsToContents() |
підігнати ширину під вміст один раз |
view.setWordWrap(False) |
не переносити довгий текст |
Режими ширини колонки (QHeaderView.ResizeMode):
| Режим | Поведінка |
|---|---|
Interactive |
користувач тягне межу (типово) |
Fixed |
ширина не змінюється |
Stretch |
ділить вільне місце порівну |
ResizeToContents |
точно під вміст, користувач змінити не може |
Тригери редагування (QAbstractItemView.EditTrigger):
| Тригер | Коли відкривається редактор |
|---|---|
NoEditTriggers |
ніколи (таблиця тільки для читання) |
DoubleClicked |
подвійне клацання |
SelectedClicked |
клацання по вже вибраній комірці |
EditKeyPressed |
F2 |
AnyKeyPressed |
будь-яка друкована клавіша |
AllEditTriggers |
усе перелічене |
Типовий набір для таблиці-списку записів:
view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
view.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
view.setAlternatingRowColors(True)
view.verticalHeader().setVisible(False)
view.horizontalHeader().setStretchLastSection(True)
resizeColumnsToContents() на великій таблиці — дорого
Щоб порахувати ширину, Qt опитує кожен рядок. На десятках тисяч записів це помітна пауза. Для великих таблиць беруть Stretch, фіксовану ширину або setResizeContentsPrecision(n), що обмежує кількість опитаних рядків.
Делегат: власний редактор і власне малювання¶
Делегат відповідає за одну комірку: як вона виглядає і чим редагується. За замовчуванням працює QStyledItemDelegate — він малює текст у стилі системи й дає QLineEdit для редагування.
| Метод | Навіщо перевизначають |
|---|---|
createEditor(parent, option, index) |
дати інший віджет-редактор (QComboBox, QSpinBox, QDateEdit) |
setEditorData(editor, index) |
покласти значення з моделі в редактор |
setModelData(editor, model, index) |
забрати значення з редактора в модель |
updateEditorGeometry(editor, option, index) |
розмістити редактор у межах комірки |
displayText(value, locale) |
змінити текст без зміни даних (формат чисел, дат) |
paint(painter, option, index) |
намалювати комірку самостійно |
sizeHint(option, index) |
сказати бажаний розмір комірки |
Встановлюють делегат на все представлення або на окрему колонку:
view.setItemDelegate(delegate) # на всю таблицю
view.setItemDelegateForColumn(2, delegate) # тільки на колонку 2
view.setItemDelegateForRow(0, delegate) # тільки на рядок 0
Приклад із двома делегатами: список фіксованих значень для посади і числове поле для зарплати.
import sys
from dataclasses import dataclass
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QHeaderView,
QSpinBox,
QStyledItemDelegate,
QTableView,
)
POSITIONS = ["Developer", "Designer", "Manager", "QA engineer"]
@dataclass
class Employee:
name: str
position: str
salary: int
class EmployeeTableModel(QAbstractTableModel):
COLUMNS = ["Name", "Position", "Salary"]
def __init__(self, employees, parent=None):
super().__init__(parent)
self._employees = list(employees)
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self._employees)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.COLUMNS)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
employee = self._employees[index.row()]
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
return [employee.name, employee.position, employee.salary][index.column()]
return None
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.COLUMNS[section]
return None
def flags(self, index):
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
return (
Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
| Qt.ItemFlag.ItemIsEditable
)
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if not index.isValid() or role != Qt.ItemDataRole.EditRole:
return False
employee = self._employees[index.row()]
column = index.column()
if column == 0:
employee.name = str(value)
elif column == 1:
employee.position = str(value)
elif column == 2:
employee.salary = int(value)
else:
return False
self.dataChanged.emit(index, index, [role])
return True
class PositionDelegate(QStyledItemDelegate):
"""Замість рядкового поля - список фіксованих значень."""
def createEditor(self, parent, option, index):
editor = QComboBox(parent)
editor.addItems(POSITIONS)
return editor
def setEditorData(self, editor, index):
value = index.data(Qt.ItemDataRole.EditRole)
position = editor.findText(str(value))
editor.setCurrentIndex(position if position >= 0 else 0)
def setModelData(self, editor, model, index):
model.setData(index, editor.currentText(), Qt.ItemDataRole.EditRole)
class SalaryDelegate(QStyledItemDelegate):
"""Числове поле з межами й кроком."""
def createEditor(self, parent, option, index):
editor = QSpinBox(parent)
editor.setRange(0, 200000)
editor.setSingleStep(500)
editor.setSuffix(" UAH")
return editor
def setEditorData(self, editor, index):
editor.setValue(int(index.data(Qt.ItemDataRole.EditRole) or 0))
def setModelData(self, editor, model, index):
editor.interpretText() # врахувати текст, який ще не підтверджено
model.setData(index, editor.value(), Qt.ItemDataRole.EditRole)
def displayText(self, value, locale):
return f"{value} UAH"
def main():
app = QApplication(sys.argv)
model = EmployeeTableModel(
[
Employee("Ivan Petrenko", "Developer", 42000),
Employee("Olena Koval", "Designer", 38000),
]
)
view = QTableView()
view.setWindowTitle("Delegates")
view.setModel(model)
view.setItemDelegateForColumn(1, PositionDelegate(view))
view.setItemDelegateForColumn(2, SalaryDelegate(view))
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
view.resize(560, 200)
view.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Зверніть увагу на розподіл обов'язків: делегат не змінює дані сам, він викликає model.setData(). Валідація лишається в моделі, навіть якщо редактор уже обмежив ввід.
displayText() — найдешевший спосіб змінити формат показу, коли модель має віддавати «сире» значення (наприклад, щоб сортування працювало за числом).
Перевизначати paint() доводиться рідше — здебільшого заради смужки прогресу, кольорового значка стану чи двох рядків тексту в одній комірці. Робиться це так:
def paint(self, painter, option, index):
if index.data(Qt.ItemDataRole.EditRole) < 0:
painter.fillRect(option.rect, QColor("#ffe0e0"))
super().paint(painter, option, index) # текст малює базовий делегат
Головне правило власного paint() — не змінювати стан painter без відновлення: painter.save() на початку й painter.restore() наприкінці, якщо ви міняли перо, пензель чи трансформацію.
Модель поверх спільного стану¶
Тепер з'єднаємо Model/View з архітектурою лекції 14. Там єдиним джерелом істини був AppState. Модель у цю схему вбудовується як перехідник: вона не володіє даними й не містить логіки — лише перекладає питання представлення на мову стану.
graph TD
ST["AppState<br/>дані та логіка"] -->|"notes_changed / note_updated"| TM["NoteTableModel<br/>перехідник"]
TM -->|"data(index, role)"| V["QTableView"]
V -->|"setData"| TM
TM -->|"state.set_title(id, ...)"| ST
Обов'язки розподіляються так:
| Хто | Робить | Не робить |
|---|---|---|
AppState |
зберігає нотатки, змінює їх, надсилає сигнали | не знає про рядки, колонки й QModelIndex |
NoteTableModel |
рахує рядки, віддає значення за ролями, перекладає setData у виклик стану |
не зберігає даних, не має правил бізнес-логіки |
MainWindow |
створює віджети, синхронізує виділення | не звертається до списку нотаток напряму |
Три правила, які роблять цю зв'язку робочою.
1. Модель тримає знімок порядку рядків. Список self._notes — не копія даних, а копія порядку: ті самі об'єкти Note, що й у стані. Він потрібен, щоб номер рядка стабільно відповідав нотатці між сигналами.
2. Кожен сигнал стану має свою реакцію. Змінився склад списку (notes_changed) — модель перечитує знімок через beginResetModel. Змінилась одна нотатка (note_updated) — модель надсилає dataChanged для одного рядка, і виділення з прокруткою лишаються на місці.
3. setData нічого не змінює власноруч. Він викликає метод стану; стан змінює дані й надсилає note_updated; модель, отримавши сигнал, надсилає dataChanged. Так зміна з таблиці й зміна з коду проходять одним шляхом, і жодне вікно не лишиться з застарілим рядком.
Збірка: застосунок «Notes» з таблицею¶
Той самий менеджер нотаток, але список замінено таблицею з трьох колонок: заголовок, час зміни, галочка «закріплено». Плюс пошук, сортування клацанням по заголовку й редагування заголовка прямо в таблиці.
notes_table/
├── main.py
└── app/
├── __init__.py
├── models.py
├── state.py
└── ui/
├── __init__.py
├── main_window.py
├── note_filter_proxy.py
└── note_table_model.py
Файли app/__init__.py та app/ui/__init__.py — порожні.
app/models.py¶
"""Шар даних. Жодних імпортів з PySide6."""
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Note:
id: int
title: str = ""
body: str = ""
updated_at: str = ""
pinned: bool = False
def display_title(self):
title = self.title.strip()
return title if title else "Untitled"
def touch(self):
self.updated_at = datetime.now().isoformat(sep=" ", timespec="seconds")
app/state.py¶
"""Шар стану. Про таблиці й віджети не знає нічого."""
from PySide6.QtCore import QObject, Signal
from .models import Note
NO_SELECTION = -1
class AppState(QObject):
notes_changed = Signal() # склад або порядок списку змінився
note_updated = Signal(int) # змінилась одна нотатка (id)
selection_changed = Signal(int) # вибрано іншу нотатку
def __init__(self, parent=None):
super().__init__(parent)
self._notes = []
self._next_id = 1
self._selected_id = NO_SELECTION
# ------------------------------------------------------------ читання
def notes(self):
return tuple(self._notes)
def count(self):
return len(self._notes)
def find(self, note_id):
for note in self._notes:
if note.id == note_id:
return note
return None
def selected_id(self):
return self._selected_id
# ----------------------------------------------------------- виділення
def select(self, note_id):
if note_id == self._selected_id:
return
self._selected_id = note_id if self.find(note_id) else NO_SELECTION
self.selection_changed.emit(self._selected_id)
# --------------------------------------------------------------- зміна
def add_note(self, title="New note", body=""):
note = Note(id=self._next_id, title=title, body=body)
note.touch()
self._next_id += 1
self._notes.append(note)
self.notes_changed.emit()
self.select(note.id)
return note.id
def delete_note(self, note_id):
note = self.find(note_id)
if note is None:
return
self._notes.remove(note)
if self._selected_id == note_id:
self._selected_id = NO_SELECTION
self.selection_changed.emit(NO_SELECTION)
self.notes_changed.emit()
def set_title(self, note_id, title):
note = self.find(note_id)
if note is None or note.title == title:
return False
note.title = title
note.touch()
self.note_updated.emit(note_id)
return True
def set_pinned(self, note_id, pinned):
note = self.find(note_id)
if note is None or note.pinned == pinned:
return False
note.pinned = pinned
note.touch()
self.note_updated.emit(note_id)
return True
app/ui/note_table_model.py¶
"""Перехідник між AppState і таблицею. Власних даних не тримає."""
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QFont
class NoteTableModel(QAbstractTableModel):
COLUMNS = ["Title", "Updated", "Pinned"]
COL_TITLE, COL_UPDATED, COL_PINNED = range(3)
def __init__(self, state, parent=None):
super().__init__(parent)
self._state = state
self._notes = list(state.notes()) # знімок порядку рядків
state.notes_changed.connect(self._reload)
state.note_updated.connect(self._on_note_updated)
# ------------------------------------------------- обов'язковий мінімум
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self._notes)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.COLUMNS)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
note = self._notes[index.row()]
column = index.column()
if role == Qt.ItemDataRole.DisplayRole:
if column == self.COL_TITLE:
return note.display_title()
if column == self.COL_UPDATED:
return note.updated_at
return None # у стовпці з галочкою тексту немає
if role == Qt.ItemDataRole.EditRole:
if column == self.COL_TITLE:
return note.title
if column == self.COL_UPDATED:
return note.updated_at
return int(note.pinned) # для сортування за галочкою
if role == Qt.ItemDataRole.CheckStateRole and column == self.COL_PINNED:
return (
Qt.CheckState.Checked if note.pinned else Qt.CheckState.Unchecked
)
if role == Qt.ItemDataRole.FontRole and note.pinned:
font = QFont()
font.setBold(True)
return font
if role == Qt.ItemDataRole.ToolTipRole:
return note.body[:80] if note.body else "Empty note"
return None
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return self.COLUMNS[section]
return section + 1
# -------------------------------------------------------- редагування
def flags(self, index):
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
if index.column() == self.COL_TITLE:
flags |= Qt.ItemFlag.ItemIsEditable
elif index.column() == self.COL_PINNED:
flags |= Qt.ItemFlag.ItemIsUserCheckable
return flags
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if not index.isValid():
return False
note = self._notes[index.row()]
if role == Qt.ItemDataRole.EditRole and index.column() == self.COL_TITLE:
# Зміну робить стан; він же надішле note_updated -> dataChanged.
return self._state.set_title(note.id, str(value).strip())
if role == Qt.ItemDataRole.CheckStateRole and index.column() == self.COL_PINNED:
checked = Qt.CheckState(value) == Qt.CheckState.Checked
return self._state.set_pinned(note.id, checked)
return False
# ------------------------------------------------------ рядок <-> id
def note_id_at(self, row):
if 0 <= row < len(self._notes):
return self._notes[row].id
return None
def row_of(self, note_id):
for row, note in enumerate(self._notes):
if note.id == note_id:
return row
return -1
# ------------------------------------------------- реакція на стан
def _reload(self):
self.beginResetModel()
self._notes = list(self._state.notes())
self.endResetModel()
def _on_note_updated(self, note_id):
row = self.row_of(note_id)
if row < 0:
return
left = self.index(row, 0)
right = self.index(row, self.columnCount() - 1)
self.dataChanged.emit(left, right)
app/ui/note_filter_proxy.py¶
"""Фільтр над NoteTableModel: пошук за заголовком і текстом."""
from PySide6.QtCore import QSortFilterProxyModel, Qt
from .note_table_model import NoteTableModel
class NoteFilterProxy(QSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self._text = ""
self.setSortRole(Qt.ItemDataRole.EditRole)
def set_search_text(self, text):
self._text = text.strip().lower()
self.invalidateFilter()
def filterAcceptsRow(self, source_row, source_parent):
if not self._text:
return True
model = self.sourceModel()
index = model.index(source_row, NoteTableModel.COL_TITLE, source_parent)
title = index.data()
body = index.data(Qt.ItemDataRole.ToolTipRole)
haystack = f"{title or ''} {body or ''}".lower()
return self._text in haystack
app/ui/main_window.py¶
"""Головне вікно: таблиця нотаток над спільним станом."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QTableView,
QVBoxLayout,
QWidget,
)
from ..state import NO_SELECTION
from .note_filter_proxy import NoteFilterProxy
from .note_table_model import NoteTableModel
class MainWindow(QMainWindow):
def __init__(self, state, parent=None):
super().__init__(parent)
self.setWindowTitle("Notes - table view")
self._state = state
self._syncing = False # захист від петлі стан <-> виділення
self._model = NoteTableModel(state, self)
self._proxy = NoteFilterProxy(self)
self._proxy.setSourceModel(self._model)
self._build_ui()
self._connect_signals()
self._update_status()
# ------------------------------------------------------------ інтерфейс
def _build_ui(self):
self._search_edit = QLineEdit()
self._search_edit.setPlaceholderText("Search notes")
self._search_edit.setClearButtonEnabled(True)
self._table = QTableView()
self._table.setModel(self._proxy)
self._table.setSortingEnabled(True)
self._table.sortByColumn(
NoteTableModel.COL_UPDATED, Qt.SortOrder.DescendingOrder
)
self._table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
header = self._table.horizontalHeader()
header.setSectionResizeMode(
NoteTableModel.COL_TITLE, QHeaderView.ResizeMode.Stretch
)
header.setSectionResizeMode(
NoteTableModel.COL_UPDATED, QHeaderView.ResizeMode.ResizeToContents
)
header.setSectionResizeMode(
NoteTableModel.COL_PINNED, QHeaderView.ResizeMode.ResizeToContents
)
self._new_button = QPushButton("New note")
self._delete_button = QPushButton("Delete note")
self._delete_button.setEnabled(False)
buttons = QHBoxLayout()
buttons.addWidget(self._new_button)
buttons.addWidget(self._delete_button)
buttons.addStretch()
self._status_label = QLabel()
central = QWidget()
layout = QVBoxLayout(central)
layout.addWidget(self._search_edit)
layout.addWidget(self._table)
layout.addLayout(buttons)
layout.addWidget(self._status_label)
self.setCentralWidget(central)
def _connect_signals(self):
self._search_edit.textChanged.connect(self._proxy.set_search_text)
self._search_edit.textChanged.connect(self._update_status)
self._new_button.clicked.connect(self._on_new_note)
self._delete_button.clicked.connect(self._on_delete_note)
self._table.selectionModel().currentChanged.connect(self._on_current_changed)
self._state.notes_changed.connect(self._on_notes_changed)
self._state.selection_changed.connect(self._on_state_selection_changed)
# --------------------------------------------------------------- слоти
def _on_new_note(self):
self._state.add_note()
def _on_delete_note(self):
note_id = self._state.selected_id()
if note_id != NO_SELECTION:
self._state.delete_note(note_id)
def _on_current_changed(self, current, previous):
if self._syncing:
return
if not current.isValid():
self._state.select(NO_SELECTION)
return
source_index = self._proxy.mapToSource(current)
note_id = self._model.note_id_at(source_index.row())
self._state.select(note_id if note_id is not None else NO_SELECTION)
def _on_notes_changed(self):
# Модель уже перебудувалась: вона під'єдналась до сигналу раніше.
self._restore_selection()
self._update_status()
def _on_state_selection_changed(self, note_id):
self._restore_selection()
self._delete_button.setEnabled(note_id != NO_SELECTION)
# ------------------------------------------------------------ приватне
def _restore_selection(self):
note_id = self._state.selected_id()
self._syncing = True
try:
if note_id == NO_SELECTION:
self._table.clearSelection()
self._table.setCurrentIndex(self._proxy.index(-1, -1))
return
row = self._model.row_of(note_id)
if row < 0:
return
source_index = self._model.index(row, NoteTableModel.COL_TITLE)
proxy_index = self._proxy.mapFromSource(source_index)
if proxy_index.isValid():
self._table.setCurrentIndex(proxy_index)
self._table.scrollTo(proxy_index)
finally:
self._syncing = False
def _update_status(self):
total = self._state.count()
shown = self._proxy.rowCount()
self._status_label.setText(f"Notes: {total} Shown: {shown}")
main.py¶
"""Точка збірки: стан створюємо один раз і передаємо вікну."""
import sys
from PySide6.QtWidgets import QApplication
from app.state import AppState, NO_SELECTION
from app.ui.main_window import MainWindow
def add_demo_notes(state):
state.add_note("Shopping list", "milk, bread, coffee")
state.add_note("Qt homework", "read about Model/View")
state.add_note("Ideas", "table view for the notes manager")
state.select(NO_SELECTION)
def main():
app = QApplication(sys.argv)
state = AppState()
add_demo_notes(state)
window = MainWindow(state)
window.resize(680, 420)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Що варто побачити в цьому коді¶
MainWindowжодного разу не звертається доnote.titleнапряму. Таблиця бере текст із моделі, модель — зі стану. Вікно керує лише виділенням і кнопками.- Порядок під'єднання до
notes_changedмає значення. Модель під'єдналась у своєму конструкторі, вікно — пізніше, у_connect_signals(). Тому в момент, коли вікно відновлює виділення, модель уже перечитала знімок. Це не випадковість, а наслідок порядку створення об'єктів у__init__. row_of()іnote_id_at()— єдині місця, де номер рядка перетворюється наidі навпаки. Ніде більше номерів рядків у логіці немає.- Виділення відновлюється в одному методі
_restore_selection(), і саме він захищений прапорцем_syncing.
Що перевірити руками¶
- Двічі клацніть по заголовку нотатки, змініть текст, натисніть
Enter— рядок оновиться, а час у колонціUpdatedзміниться. - Поставте галочку
Pinned— заголовок стане жирним. Дані змінює стан, шрифт віддає модель черезFontRole. - Клацніть по заголовку колонки
Title— таблиця відсортується; клацніть ще раз — у зворотному порядку. - Введіть у пошук частину слова з тексту нотатки (наприклад,
coffee) — знайдеться нотатка, у якої це слово лише в тілі, боfilterAcceptsRowдивиться і в підказку. - Виберіть рядок і натисніть
Delete note— рядок зникне, кнопка знову стане неактивною. - Натисніть
New noteпід час активного фільтра — нова нотатка може не пройти фільтр і не з'явитись у таблиці, хоча лічильникNotes:збільшиться. Це нормальна поведінка фільтра; порівняйтеNotes:іShown:.
Коли Model/View не потрібен¶
Model/View — не безумовне благо: він додає класи й шар непрямості. Орієнтир такий:
| Ситуація | Рішення |
|---|---|
| Список із 3–10 фіксованих пунктів, більше ніде не потрібний | QListWidget / QComboBox |
| Таблиця, яку заповнили один раз і лише читають | QTableWidget |
Дані вже є в AppState або в базі |
власна модель |
| Ті самі дані видно у двох місцях | власна модель |
| Треба сортування, фільтрація або пошук | модель + QSortFilterProxyModel |
| Тисячі рядків | обов'язково модель |
| Дані оновлюються ззовні (таймер, мережа, файл) | обов'язково модель |
Практичне правило: якщо ви ловите себе на тому, що пишете код «пройтись по віджету й оновити його з даних» — вам потрібна модель.
Типові помилки¶
1. Зміна списку без begin/end
Рядок не з'явиться, а прокрутка чи сортування можуть закінчитись падінням. Обгортайте в beginInsertRows / endInsertRows.
2. rowCount() не перевіряє parent
Для табличної моделі при дійсному parent треба повертати 0.
3. setData() без dataChanged
Дані змінились, комірка показує старе. Сигнал обов'язковий — або, як у збірці вище, його надсилає модель у відповідь на сигнал стану.
4. setData() завжди повертає None
Метод без return повертає None, що для Qt означає False: редактор не закривається, і здається, що «редагування не працює».
5. Немає ItemIsEditable у flags()
Подвійне клацання нічого не відкриває, хоча setData() написано. Прапорці й setData завжди йдуть у парі.
6. data() повертає значення для будь-якої ролі
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
return self._notes[index.row()].title # ПОМИЛКА
Той самий рядок піде і в FontRole, і в DecorationRole, і в SizeHintRole. Наслідок — дивний вигляд або падіння.
7. Немає перевірки index.isValid()
Qt регулярно передає недійсний індекс. self._notes[index.row()] при row() == -1 мовчки візьме останній елемент списку — і ви довго шукатимете, звідки взялись «не ті» дані.
8. Номер рядка проксі використали як номер у джерелі
Спершу proxy.mapToSource(index), а ще краще — id запису.
9. Сортування чисел за DisplayRole
"9000" виявляється більшим за "51000". Лікується EditRole + proxy.setSortRole(Qt.ItemDataRole.EditRole).
10. QModelIndex збережено в полі об'єкта
Після скидання чи видалення індекс вказує в порожнечу. Зберігайте id, а індекс беріть заново.
11. beginResetModel() на кожен чих
Скидання після кожного натискання клавіші губить виділення й позицію прокрутки. Для зміни однієї комірки є dataChanged.
12. endInsertRows() забули або пропустили через виняток
Модель лишається в «перехідному» стані, і наступні операції поводяться непередбачувано. Якщо між begin і end можливий виняток — ставте try / finally.
13. setModel() на QTableWidget
Не спрацює: у зручного віджета вже є своя модель. Потрібен QTableView.
14. Модель зберігає копію даних стану
Копія застаріє після першої ж зміни. Модель тримає знімок порядку, а не копію об'єктів.
15. Петля «виділення → стан → виділення»
Слот currentChanged кличе state.select(), стан надсилає сигнал, вікно ставить виділення, представлення знову надсилає currentChanged. Рятує прапорець _syncing.
16. Важкі обчислення в data()
Запит до бази чи читання файлу всередині data() виконуються сотні разів на прокрутку. Готуйте значення заздалегідь.
17. Модель змінюють з іншого потоку
Усі виклики begin*/end* і dataChanged мають відбуватись у головному потоці. Робота з потоками — тема наступних лекцій; поки що просто не робіть цього.
Підсумок¶
- Model/View прибирає копію даних. Представлення нічого не зберігає: воно питає модель
data(index, role)щоразу, коли малює комірку. Тому одна модель може живити скільки завгодно вікон. - Qt реалізує спрощений MVC: контролер злився з представленням, натомість з'явився делегат, що малює комірку та створює для неї редактор.
QModelIndex— адреса комірки: рядок, колонка, батько. Індекси видає модель, вони недовговічні, і зберігати їх не можна: зберігаютьidзапису.- Ролі розділяють призначення значень однієї комірки:
DisplayRole— що видно,EditRole— що редагується й сортується,CheckStateRole— галочка,UserRole— власні дані. На невідому роль модель повертаєNone. - Власна модель — це
QAbstractTableModelіз чотирма методами:rowCount,columnCount,data,headerData. У таблиціrowCountіcolumnCountповертають0для дійсногоparent. - Редагування вмикають разом:
flags()додаєItemIsEditable,setData()перевіряє значення, змінює дані, надсилаєdataChangedі повертаєTrue.False— штатний спосіб відхилити ввід. - Будь-яка зміна структури обгортається парою
beginInsertRows/endInsertRows,beginRemoveRows/endRemoveRows.beginResetModel— тільки для заміни всього набору даних, бо він губить виділення й прокрутку. QSortFilterProxyModelдає сортування й фільтрацію без жодного рядка в моделі. Представлення підключають до проксі, а індекси перекладають черезmapToSource/mapFromSource. Числа сортуються правильно завдякиsetSortRole(EditRole).- Виділення — окремий об'єкт
QItemSelectionModelіз сигналамиcurrentChangedіselectionChanged. Синхронізацію зі станом захищають прапорцем від петлі. - Делегат не змінює дані сам: він створює редактор і викликає
model.setData().displayText()міняє формат показу,paint()— вигляд комірки. - У застосунку з
AppStateмодель працює перехідником: даних не тримає, логіки не має, наnotes_changedперечитує знімок, наnote_updatedнадсилаєdataChanged, аsetDataперекладає у виклик методу стану. - Для трьох статичних пунктів
QListWidgetлишається правильним вибором. Модель потрібна там, де дані вже існують окремо, показуються більш ніж в одному місці, фільтруються або обчислюються тисячами.
Корисні посилання¶
- Qt: Model/View Programming
- QAbstractItemModel
- QAbstractTableModel
- QModelIndex
- Qt.ItemDataRole — перелік ролей
- Qt.ItemFlag — прапорці комірки
- QSortFilterProxyModel
- QTableView
- QHeaderView
- QItemSelectionModel
- QStyledItemDelegate
- QStandardItemModel
Домашнє завдання¶
Запустити застосунок «Notes» із таблицею та пройти всі шість перевірок із розділу «Що перевірити руками». Далі додати до моделі четверту колонку Length — кількість символів у тілі нотатки — так, щоб вона:
- показувала число, а не текст (
DisplayRoleіEditRoleмають відрізнятись за форматом); - правильно сортувалась як число;
- була недоступною для редагування (без
ItemIsEditable); - оновлювалась разом із рядком, коли змінюється нотатка.
Знайшли помилку чи бажаєте додати інформацію, щоб покращити курс? Створіть issue на GitHub