https://doc.qt.io/qt-5/model-view-programming.html#model-classes
Model View Architecture
As you start to build more complex applications with PyQt6 you’ll likely come across issues keeping widgets in sync with your data.
Data stored in widgets (e.g. a simple QListWidget) is not easy to manipulate from Python — changes require you to get an item, get the data, and then set it back. The default solution to this is to keep an external data representation in Python, and then either duplicate updates to the both the data and the widget, or simply rewrite the whole widget from the data. As you start to work with larger data this approach can start to have performance impacts on your application.
Thankfully Qt has a solution for this — ModelViews. ModelViews are a powerful alternative to the standard display widgets, which use a standardized model interface to interact with data sources — from simple data structures to external databases. This isolates your data, meaning you can keep it in any structure you like, while the view takes care of presentation and updates.
This chapter introduces the key aspects of Qt’s ModelView architecture and uses it to build a simple desktop Todo application in PyQt6.
The Model View Architecture — Model View Controller
Model–View–Controller (MVC) is an architectural pattern used for developing user interfaces. It divides an application into three interconnected parts, separating the internal representation of data from how it is presented to and accepted from the user.
The MVC pattern splits the interface into the following components —
- Model holds the data structure which the app is working with.
- View is any representation of information as shown to the user, whether graphical or tables. Multiple views of the same data are allowed.
- Controller accepts input from the user, transforms it into commands and applies these to the model or view.
In Qt land the distinction between the View & Controller gets a little murky. Qt accepts input events from the user via the OS and delegates these to the widgets (Controller) to handle. However, widgets also handle presentation of their own state to the user, putting them squarely in the View. Rather than agonize over where to draw the line, in Qt-speak the View and Controller are instead merged together creating a Model/ViewController architecture — called "Model View" for simplicity.
Figure 133. Comparing the MVC model and the Qt Model/View architecture.
Importantly, the distinction between the data and how it is presented is preserved.
The Model View
You can use any "data store" for your model, including for example a standard Python list or dictionary, or a database (via Qt itself, or SQLAlchemy) — it’s entirely up to you.
The two parts are essentially responsible for —
- The model stores the data, or a reference to it and returns individual or ranges of records, and associated metadata or display instructions.
- The view requests data from the model and displays what is returned on the widget.
A simple Model View — a Todo List
실제로 ModelView를 사용하는 방법을 보여 주기 위해 데스크톱 Todo List의 매우 간단한 구현을 함께 살펴보겠습니다. 이것은 항목 목록에 대한 QListView, 새 항목을 입력하는 QLineEdit 및 항목을 추가, 삭제 또는 완료로 표시하는 버튼 세트로 구성됩니다.
The UI
model-views/todo_skeleton.py
import sys
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from MainWindow import Ui_MainWindow
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
The widgets in the interface were given the IDs shown in the table below.
objectName | Type | Description |
todoView | QListView | The list of current todos |
todoEdit | QLineEdit | The text input for creating a new todo item |
addButton | QPushButton | Create the new todo, adding it to the todos list |
deleteButton | QPushButton | Delete the current selected todo, removing it from the todos list |
completeButton | QPushButton | Mark the current selected todo as done |
The Model
model-views/todo_1.py
import sys
from PyQt6.QtCore import Qt, QAbstractListModel
from PyQt6.QtWidgets import QMainWindow, QApplication
from MainWindow import Ui_MainWindow
class TodoModel(QAbstractListModel):
def __init__(self, todos=None):
super().__init__()
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
def rowCount(self, index):
return len(self.todos)
'.todos'변수는 데이터 저장소입니다. 두 가지 메서드 rowcount() 및 data()는 목록 모델에 대해 구현해야 하는 표준 모델 메서드입니다. 아래에서 차례로 살펴보겠습니다.
.todos list
[(bool, str), (bool, str), (bool, str)]
where bool is the done state of a given entry, and str is the text of the todo.
.rowcount()
The .rowcount() method is called by the view to get the number of rows in the current data. This is required for the view to know the maximum index it can request from the data store (rowcount - 1). Since we’re using a Python list as our data store, the return value for this is simply the len() of the list.
.data()
This is the core of your model, which handles requests for data from the view and returns the appropriate result. It receives two parameters index and role.
index is the position/coordinates of the data which the view is requesting, accessible by two methods .row() and .column() which give the position in each dimension. For a list view, column can be ignored.
For our QListView, the column is always 0 and can be ignored.
But you would need to use this for 2D data, for example in a spreadsheet view.
role is a flag indicating the type of data the view is requesting. This is because the .data() method actually has more responsibility than just the core data. It also handles requests for style information, tooltips, status bars, etc. — basically anything that could be informed by the data itself.
The naming of Qt.ItemDataRole.DisplayRole is a bit weird, but this indicates that the view is asking us "please give me data for display". There are other roles which the data can receive for styling requests or requesting data in "edit-ready" format.
Role | Value | Description |
Qt.ItemDataRole.DisplayRole | 0 | The key data to be rendered in the form of text. QString |
Qt.ItemDataRole.DecorationRole | 1 | The data to be rendered as a decoration in the form of an icon. QColor, QIcon or QPixmap |
Qt.ItemDataRole.EditRole | 2 | The data in a form suitable for editing in an editor. QString |
Qt.ItemDataRole.ToolTipRole | 3 | The data displayed in the item’s tooltip. QString |
Qt.ItemDataRole.StatusTipRole | 4 | The data displayed in the status bar. QString |
Qt.ItemDataRole.WhatsThisRole | 5 | The data displayed for the item in "What’s This?" mode. QString |
Qt.ItemDataRole.SizeHintRole | 13 | The size hint for the item that will be supplied to views. QSize |
Basic implementation
model-views/todo_1b.py
import sys
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtWidgets import QApplication, QMainWindow
from MainWindow import Ui_MainWindow
class TodoModel(QAbstractListModel):
def __init__(self, todos=None):
super().__init__()
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
def rowCount(self, index):
return len(self.todos)
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
self.model = TodoModel()
self.todoView.setModel(self.model)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
We define our TodoModel as before and initialize the MainWindow object. In the init for the MainWindow we create an instance of our todo model and set this model on the todo_view. Save this file as todo.py and run it with —
python3 todo.py
While there isn’t much to see yet, the QListView and our model are actually working — if you add some default data to the TodoModel in the MainWindow class you’ll see it appear in the list.
self.model = TodoModel(todos=[(False, 'my first todo')])
Figure 136. QListView showing hard-coded todo item
model-views/todo_2.py
import sys
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtWidgets import QApplication, QMainWindow
from MainWindow import Ui_MainWindow
class TodoModel(QAbstractListModel):
def __init__(self, todos=None):
super().__init__()
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
def rowCount(self, index):
return len(self.todos)
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
self.model = TodoModel()
self.todoView.setModel(self.model)
# Connect the button.
self.addButton.pressed.connect(self.add)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
# Remove whitespace from the ends of the string.
text = text.strip()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit() # <1>
# Empty the input
self.todoEdit.setText("")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
데이터만 바뀔 경우 : .dataChanged() signal 사용
Hooking up the other actions
model-views/todo_3.py
import sys
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtWidgets import QApplication, QMainWindow
from MainWindow import Ui_MainWindow
class TodoModel(QAbstractListModel):
def __init__(self, todos=None):
super().__init__()
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
def rowCount(self, index):
return len(self.todos)
class MainWindow(QMainWindow, Ui_MainWindow):
# end::MainWindow[]
def __init__(self):
super().__init__()
self.setupUi(self)
self.model = TodoModel()
self.todoView.setModel(self.model)
# Connect the button.
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
def add(self):
"""
Add an item to our todo list, getting the text from
the QLineEdit .todoEdit and then clearing it.
"""
text = self.todoEdit.text()
# Remove whitespace from the ends of the string.
text = text.strip()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit() # <1>
# Empty the input
self.todoEdit.setText("")
# tag::delete[]
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a single-item list in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
# end::delete[]
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
We use self.todoView.selectedIndexes to get the indexes (actually a list of a single item, as we’re in single-selection mode) and then use the .row() as an index into our list of todos on our model. We delete the indexed item using Python’s del operator, and then trigger a layoutChanged signal because the shape of the data has been modified.
Finally, we clear the active selection since the item you selected is now gone and the position itself could be out of bounds (if you had selected the last item).
model-views/todo_4.py
import sys
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtWidgets import QApplication, QMainWindow
from MainWindow import Ui_MainWindow
class TodoModel(QAbstractListModel):
def __init__(self, todos=None):
super().__init__()
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
def rowCount(self, index):
return len(self.todos)
class MainWindow(QMainWindow, Ui_MainWindow):
# end::MainWindow[]
def __init__(self):
super().__init__()
self.setupUi(self)
self.model = TodoModel()
self.todoView.setModel(self.model)
# Connect the button.
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
self.completeButton.pressed.connect(self.complete)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
# Remove whitespace from the ends of the string.
text = text.strip()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit() # <1>
# Empty the input
self.todoEdit.setText("")
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a single-item list in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
# tag::complete[]
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
index = indexes[0]
row = index.row()
status, text = self.model.todos[row]
self.model.todos[row] = (True, text)
# .dataChanged takes top-left and bottom right, which are equal
# for a single selection.
self.model.dataChanged.emit(index, index)
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
# end::complete[]
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Using DecorationRole
항목이 완료될 때 표시할 표시기를 뷰에 제공하도록 모델을 업데이트해야 합니다. 업데이트된 모델은 다음과 같습니다.
model-views/todo_5.py
import os
import sys
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication, QMainWindow
from MainWindow import Ui_MainWindow
basedir = os.path.dirname(__file__)
tick = QImage(os.path.join(basedir, "tick.png"))
class TodoModel(QAbstractListModel):
def __init__(self, *args, todos=None, **kwargs):
super(TodoModel, self).__init__(*args, **kwargs)
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
if role == Qt.ItemDataRole.DecorationRole:
status, text = self.todos[index.row()]
if status:
return tick
def rowCount(self, index):
return len(self.todos)
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
self.model = TodoModel()
self.todoView.setModel(self.model)
# Connect the button.
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
self.completeButton.pressed.connect(self.complete)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
# Remove whitespace from the ends of the string.
text = text.strip()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit() # <1>
# Empty the input
self.todoEdit.setText("")
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a single-item list in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
index = indexes[0]
row = index.row()
status, text = self.model.todos[row]
self.model.todos[row] = (True, text)
# .dataChanged takes top-left and bottom right, which are equal
# for a single selection.
self.model.dataChanged.emit(index, index)
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
A persistent data store
model-views/todo_6.py
import json
import os
import sys
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication, QMainWindow
from MainWindow import Ui_MainWindow
basedir = os.path.dirname(__file__)
tick = QImage(os.path.join("tick.png"))
class TodoModel(QAbstractListModel):
def __init__(self, *args, todos=None, **kwargs):
super(TodoModel, self).__init__(*args, **kwargs)
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
if role == Qt.ItemDataRole.DecorationRole:
status, text = self.todos[index.row()]
if status:
return tick
def rowCount(self, index):
return len(self.todos)
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
self.model = TodoModel()
self.todoView.setModel(self.model)
# Connect the button.
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
self.completeButton.pressed.connect(self.complete)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
# Remove whitespace from the ends of the string.
text = text.strip()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit() # <1>
# Empty the input
self.todoEdit.setText("")
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a single-item list in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
index = indexes[0]
row = index.row()
status, text = self.model.todos[row]
self.model.todos[row] = (True, text)
# .dataChanged takes top-left and bottom right, which are equal
# for a single selection.
self.model.dataChanged.emit(index, index)
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
# tag::loadsave[]
def load(self):
try:
with open("data.json", "r") as f:
self.model.todos = json.load(f)
except Exception:
pass
def save(self):
with open("data.json", "w") as f:
data = json.dump(self.model.todos, f)
# end::loadsave[]
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
mode-views/todo_complete.py
import json
import os
import sys
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication, QMainWindow
from MainWindow import Ui_MainWindow
basedir = os.path.dirname(__file__)
tick = QImage(os.path.join(basedir, "tick.png"))
class TodoModel(QAbstractListModel):
def __init__(self, todos=None):
super().__init__()
self.todos = todos or []
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
status, text = self.todos[index.row()]
return text
if role == Qt.ItemDataRole.DecorationRole:
status, text = self.todos[index.row()]
if status:
return tick
def rowCount(self, index):
return len(self.todos)
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
self.model = TodoModel()
self.load()
self.todoView.setModel(self.model)
self.addButton.pressed.connect(self.add)
self.deleteButton.pressed.connect(self.delete)
self.completeButton.pressed.connect(self.complete)
def add(self):
"""
Add an item to our todo list, getting the text from the QLineEdit .todoEdit
and then clearing it.
"""
text = self.todoEdit.text()
# Remove whitespace from the ends of the string.
text = text.strip()
if text: # Don't add empty strings.
# Access the list via the model.
self.model.todos.append((False, text))
# Trigger refresh.
self.model.layoutChanged.emit()
# Empty the input
self.todoEdit.setText("")
self.save()
def delete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
# Indexes is a single-item list in single-select mode.
index = indexes[0]
# Remove the item and refresh.
del self.model.todos[index.row()]
self.model.layoutChanged.emit()
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def complete(self):
indexes = self.todoView.selectedIndexes()
if indexes:
index = indexes[0]
row = index.row()
status, text = self.model.todos[row]
self.model.todos[row] = (True, text)
# .dataChanged takes top-left and bottom right, which are equal
# for a single selection.
self.model.dataChanged.emit(index, index)
# Clear the selection (as it is no longer valid).
self.todoView.clearSelection()
self.save()
def load(self):
try:
with open("data.json", "r") as f:
self.model.todos = json.load(f)
except Exception:
pass
def save(self):
with open("data.json", "w") as f:
data = json.dump(self.model.todos, f)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
'PyQt5_' 카테고리의 다른 글
Querying SQL databases with Qt models (0) | 2023.03.13 |
---|---|
Tabular data in ModelViews, with numpy & pandas (0) | 2023.03.13 |
Qt Style Sheets (QSS) (0) | 2023.03.13 |
Icons (0) | 2023.03.13 |
Palettes (0) | 2023.03.13 |
댓글