본문 바로가기
PyQt5_

The Model View Architecture — Model View Controller

by 자동매매 2023. 3. 13.

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

모델은 데이터 저장소와 ViewController 간의 인터페이스 역할을 합니다. 모델은 데이터(또는 데이터에 대한 참조)를 보유하고 뷰가 소비하고 사용자에게 제공하는 표준화된 API를 통해 이 데이터를 제공합니다. 여러 뷰는 동일한 데이터를 공유하여 완전히 다른 방식으로 표시할 수 있습니다.

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 —

  1. The model stores the data, or a reference to it and returns individual or ranges of records, and associated metadata or display instructions.
  2. 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 항목을 추가, 삭제 또는 완료로 표시하는 버튼 세트로 구성됩니다.

mainwindow.ui
0.00MB
MainWindow.py
0.00MB
todo_skeleton.py
0.00MB

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()

 

표준 Qt 위젯과 주요 차이점은 데이터를 직접 변경하고 Qt에 일부 변경 사항이 발생했음을 알리기 만하면 위젯 상태 업데이트가 자동으로 처리된다는 것입니다.

 

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

 

해결책은 일종의 영구 데이터 저장소를 구현하는 것입니다. 가장 간단한 방법은 시작시 JSON 또는 Pickle 파일에서 항목을로드하고 변경 사항을 다시 쓰는 간단한 파일 저장소입니다.

 

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()

 

데이터에 대한 변경 사항을 유지하려면 데이터를 수정하는 메서드의 끝에 .save() 핸들러를 추가하고 모델이 생성된 후 init 블록에 .load() 핸들러를 추가해야 합니다.

 

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()
응용 프로그램의 데이터가 커지거나 복잡해질 가능성이 있는 경우 실제 데이터베이스를 사용하여 저장하는 것이 좋습니다. Qt는 곧 다룰 SQL 데이터베이스와 상호 작용하기위한 모델을 제공합니다.

 

 

QListView 의 또 다른 흥미로운 예제는 예제 미디어 플레이어 응용 프로그램을 참조하십시오. 이것은 Qt 내장 QMediaPlaylist 를 데이터 저장소로 사용하고 내용은 QListView에 표시됩니다.

 

'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

댓글