Skip to content

MVP Model View Presenter

MVP splits your UI code into three roles:

1️⃣ Model — data & business logic - Holds application state - Knows nothing about Qt or UI - Example: data classes, domain logic, timers, IO, calculations

2️⃣ View — UI only - Widgets, layouts, rendering - Emits user intent (signals like clicked, textChanged) - Has no business logic - Does not pull data from Model

3️⃣ Presenter — the glue - Listens to View events - Updates the Model - Listens to Model changes - Pushes updates to the View


Simple Demo

"""
Minimal PyQt6 MVP example

- Model: plain Python (no Qt)
- View: QWidget + signals (no logic)
- Presenter: connects View ↔ Model

Run:
  pip install PyQt6
  python minimal_mvp.py
"""

from __future__ import annotations
import sys
from dataclasses import dataclass

from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import (
    QApplication,
    QWidget,
    QVBoxLayout,
    QLabel,
    QLineEdit,
)


# -------------------------
# Model (Qt-free)
# -------------------------

@dataclass
class GreetingModel:
    name: str = ""

    def greeting(self) -> str:
        n = self.name.strip()
        return f"Hello, {n}!" if n else "Hello!"


# -------------------------
# View (UI only)
# -------------------------

class GreetingView(QWidget):
    # User intent exposed as signals
    nameChanged = pyqtSignal(str)

    def __init__(self) -> None:
        super().__init__()

        self.edit = QLineEdit()
        self.label = QLabel("Hello!")

        layout = QVBoxLayout(self)
        layout.addWidget(QLabel("Name:"))
        layout.addWidget(self.edit)
        layout.addWidget(QLabel("Greeting:"))
        layout.addWidget(self.label)

        # UI → View signals
        self.edit.textChanged.connect(self.nameChanged.emit)

    # Presenter → View API
    def set_greeting(self, text: str) -> None:
        self.label.setText(text)


# -------------------------
# Presenter (glue)
# -------------------------

class GreetingPresenter(QObject):
    def __init__(self, model: GreetingModel, view: GreetingView) -> None:
        super().__init__()
        self.model = model
        self.view = view

        # View → Presenter
        self.view.nameChanged.connect(self.on_name_changed)

        # Initial render
        self.view.set_greeting(self.model.greeting())

    @pyqtSlot(str)
    def on_name_changed(self, text: str) -> None:
        self.model.name = text
        self.view.set_greeting(self.model.greeting())


# -------------------------
# Main
# -------------------------

def main() -> int:
    app = QApplication(sys.argv)

    model = GreetingModel()
    view = GreetingView()
    presenter = GreetingPresenter(model, view)  # keep reference!

    view.setWindowTitle("Minimal PyQt6 MVP")
    view.resize(300, 150)
    view.show()

    return app.exec()


if __name__ == "__main__":
    raise SystemExit(main())

Model notify variant


Brief remainder

Qt uses signals & slots to implement an event-driven, observer pattern.
pyqtSignal declares events; pyqtSlot receives them safely and efficiently.

  • Signal → “Something happened”
  • Slot → “What should run when it happens”

pyqtSignal

  • Declared at class level
  • Signal type is defined by argument list
  • Emits with .emit(...)
  • One signal → many receivers
declare
from PyQt6.QtCore import QObject, pyqtSignal

class GreetingView(QWidget):
    # User intent exposed as signals
    nameChanged = pyqtSignal(str)

    def __init__(self) -> None:
        super().__init__()

        self.edit = QLineEdit()
        self.label = QLabel("Hello!")

        #...

        # UI → View signals
        self.edit.textChanged.connect(self.nameChanged.emit)

pyqtSlot

pyqtSlot marks a method as a Qt slot.

1
2
3
4
5
6
7
from PyQt6.QtCore import pyqtSlot

class GreetingPresenter(QObject):
    @pyqtSlot(str)
    def on_name_changed(self, text: str) -> None:
        self.model.name = text
        self.view.set_greeting(self.model.greeting())

Reference