Skip to content

Widget[T] Base Class

The Widget[T] base class is the foundation of model-driven widgets in QtPie. It provides automatic record binding, validation, dirty tracking, undo/redo, and save/load functionality.

Overview

Widget[T] can be used in two ways:

  1. Without type parameter - Just a mixin, no record binding
  2. With type parameter - Enables automatic record binding and reactive features
from dataclasses import dataclass
from qtpy.QtWidgets import QWidget, QLineEdit, QSpinBox
from qtpie import Widget, widget, make

# Without type parameter - simple widget
@widget
class SimpleWidget(QWidget, Widget):
    label: QLabel = make(QLabel, "Hello")

# With type parameter - model-bound widget
@dataclass
class Person:
    name: str = ""
    age: int = 0

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)  # Auto-binds to record.name
    age: QSpinBox = make(QSpinBox)      # Auto-binds to record.age

Record & Observable Proxy Attributes

When using Widget[T], two key attributes are automatically created:

record: T

The underlying data record. This is the source of truth for your data.

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)

w = PersonEditor()
print(w.record.name)  # Access model directly
w.record.name = "Bob"  # Direct assignment (won't trigger UI update)

Model Creation:

By default, the model is auto-created as T():

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)

w = PersonEditor()
# model = Person() automatically created
assert w.record.name == ""

Custom Model:

Use make() to provide a custom initial model:

@widget
class PersonEditor(QWidget, Widget[Person]):
    record: Person = make(Person, name="Alice", age=30)
    name: QLineEdit = make(QLineEdit)

w = PersonEditor()
assert w.record.name == "Alice"

Deferred Initialization:

Use make_later() for manual setup:

@widget
class PersonEditor(QWidget, Widget[Person]):
    record: Person = make_later()
    name: QLineEdit = make(QLineEdit)

    def setup(self) -> None:
        self.record = Person(name="Charlie", age=25)

record_observable_proxy: ObservableProxy[T]

An ObservableProxy wrapper around the model that enables reactive bindings. Changes to the proxy automatically update bound widgets, and widget changes update the proxy. See Observant (PyPI) for more on the underlying reactive system.

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)

w = PersonEditor()

# Changes via proxy trigger UI updates
w.record_observable_proxy.observable(str, "name").set("Alice")
assert w.name.text() == "Alice"

# Widget changes update the proxy (and model)
w.name.setText("Bob")
assert w.record_observable_proxy.observable(str, "name").get() == "Bob"
assert w.record.name == "Bob"

Automatic Binding

By default (auto_bind=True), widget fields automatically bind to model properties when their names match:

@dataclass
class Person:
    name: str = ""
    age: int = 0
    active: bool = False

@widget
class PersonEditor(QWidget, Widget[Person]):
    # These auto-bind by name matching:
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)
    active: QCheckBox = make(QCheckBox)

    # This doesn't match any model field - no binding:
    submit: QPushButton = make(QPushButton, "Submit")

w = PersonEditor()

# Two-way binding works automatically:
w.name.setText("Alice")
assert w.record.name == "Alice"

w.record_observable_proxy.observable(int, "age").set(30)
assert w.age.value() == 30

Disabling Auto-Binding

Use auto_bind=False to disable automatic name-based binding:

@widget(auto_bind=False)
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)  # NOT auto-bound
    age: QSpinBox = make(QSpinBox, bind="age")  # Explicit binding still works

w = PersonEditor()

w.name.setText("Alice")
assert w.record.name == ""  # No binding occurred

w.age.setValue(30)
assert w.record.age == 30  # Explicit binding works

set_record()

Change the model after widget creation and rebind all widgets.

def set_record(self, record: T) -> None

Example:

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)

w = PersonEditor()
assert w.name.text() == ""

# Switch to a different person
new_person = Person(name="Alice", age=30)
w.set_record(new_person)

assert w.record == new_person
assert w.record.name == "Alice"

Validation Methods

Validation is powered by the underlying ObservableProxy. All validation methods delegate to self.record_observable_proxy.

add_validator()

Add a validation rule to a field.

def add_validator(self, field: str, validator: Callable[[Any], str | None]) -> None

Parameters: - field: The field name to validate - validator: Function that returns None if valid, or an error message if invalid

Example:

@dataclass
class User:
    name: str = ""
    age: int = 0
    email: str = ""

@widget
class UserEditor(QWidget, Widget[User]):
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)
    email: QLineEdit = make(QLineEdit)

    def setup(self) -> None:
        # Add validation rules
        self.add_validator("name", lambda v: "Name required" if not v else None)
        self.add_validator("age", lambda v: "Must be 18+" if v < 18 else None)
        self.add_validator("email", lambda v: "Invalid email" if "@" not in v else None)

is_valid()

Get an observable indicating whether all fields are valid.

def is_valid(self) -> Observable[bool]

Returns: Observable that emits True when all validators pass, False otherwise.

Example:

@widget
class UserEditor(QWidget, Widget[User]):
    name: QLineEdit = make(QLineEdit)
    save_btn: QPushButton = make(QPushButton, "Save")

    def setup(self) -> None:
        self.add_validator("name", lambda v: "Required" if not v else None)

        # Enable save button only when form is valid
        self.is_valid().on_change(lambda valid: self.save_btn.setEnabled(valid))

w = UserEditor()
assert w.is_valid().get() is False  # Empty name is invalid

w.name.setText("Alice")
assert w.is_valid().get() is True  # Now valid

validation_for()

Get an observable list of validation errors for a specific field.

def validation_for(self, field: str) -> Observable[list[str]]

Parameters: - field: The field name

Returns: Observable list of error messages (empty if valid)

Example:

@widget
class UserEditor(QWidget, Widget[User]):
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)
    name_error: QLabel = make(QLabel)
    age_error: QLabel = make(QLabel)

    def setup(self) -> None:
        self.add_validator("name", lambda v: "Name required" if not v else None)
        self.add_validator("age", lambda v: "Must be 18+" if v < 18 else None)

        # Show errors for each field
        self.validation_for("name").on_change(self.show_name_errors)
        self.validation_for("age").on_change(self.show_age_errors)

    def show_name_errors(self, errors: list[str]) -> None:
        self.name_error.setText(", ".join(errors) if errors else "")

    def show_age_errors(self, errors: list[str]) -> None:
        self.age_error.setText(", ".join(errors) if errors else "")

w = UserEditor()
name_errors = w.validation_for("name").get()
assert "Name required" in name_errors

validation_errors()

Get an observable dict of all validation errors.

def validation_errors(self) -> ObservableDict[str, list[str]]

Returns: Observable dictionary mapping field names to lists of error messages

Example:

@widget
class UserEditor(QWidget, Widget[User]):
    name: QLineEdit = make(QLineEdit)
    email: QLineEdit = make(QLineEdit)

    def setup(self) -> None:
        self.add_validator("name", lambda v: "Required" if not v else None)
        self.add_validator("email", lambda v: "Invalid" if "@" not in v else None)

w = UserEditor()

# Get all errors as a dict
all_errors = w.validation_errors()
name_errors = all_errors.get("name", [])
email_errors = all_errors.get("email", [])

assert "Required" in name_errors
assert "Invalid" in email_errors

Dirty Tracking Methods

Dirty tracking lets you detect which fields have been modified since the last reset.

is_dirty()

Check whether any field has been modified.

def is_dirty(self) -> bool

Returns: True if any field is dirty, False otherwise

Example:

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)

w = PersonEditor()
assert w.is_dirty() is False  # Initially clean

w.name.setText("Alice")
assert w.is_dirty() is True  # Now dirty

dirty_fields()

Get the set of dirty field names.

def dirty_fields(self) -> set[str]

Returns: Set of field names that have been modified

Example:

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)

w = PersonEditor()

w.name.setText("Alice")
assert "name" in w.dirty_fields()
assert "age" not in w.dirty_fields()

w.age.setValue(30)
assert "age" in w.dirty_fields()

reset_dirty()

Reset dirty state, making current values the new baseline.

def reset_dirty(self) -> None

Example:

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)
    save_btn: QPushButton = make(QPushButton, "Save", clicked="save")

    def save(self) -> None:
        # Save to database...
        self.save_to(self.record)
        self.reset_dirty()  # Mark as clean after save

w = PersonEditor()
w.name.setText("Alice")
assert w.is_dirty() is True

w.save()
assert w.is_dirty() is False  # Clean after save

Common Pattern: Unsaved Changes Warning

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)
    status: QLabel = make(QLabel)

    def setup(self) -> None:
        # Show status when dirty state changes
        self.record_observable_proxy.is_dirty_observable().on_change(self.update_status)

    def update_status(self, dirty: bool) -> None:
        if dirty:
            self.status.setText("* Unsaved changes")
        else:
            self.status.setText("Saved")

Undo/Redo Methods

Undo/redo functionality requires enabling it in the @widget decorator.

Enable undo:

@widget(undo=True)
class TextEditor(QWidget, Widget[Document]):
    content: QTextEdit = make(QTextEdit)

Configure undo:

@widget(
    undo=True,
    undo_max=50,          # Max 50 history entries per field (default: 20)
    undo_debounce_ms=500  # Debounce rapid changes (default: 300ms)
)
class TextEditor(QWidget, Widget[Document]):
    content: QTextEdit = make(QTextEdit)

undo()

Undo the last change to a field.

def undo(self, field: str) -> None

Parameters: - field: The field name

Example:

@widget(undo=True)
class TextEditor(QWidget, Widget[Document]):
    content: QLineEdit = make(QLineEdit)

w = TextEditor()

w.content.setText("Alice")
assert w.record_observable_proxy.observable(str, "content").get() == "Alice"

w.undo("content")
assert w.record_observable_proxy.observable(str, "content").get() == ""  # Reverted

redo()

Redo the last undone change to a field.

def redo(self, field: str) -> None

Parameters: - field: The field name

Example:

@widget(undo=True)
class TextEditor(QWidget, Widget[Document]):
    content: QLineEdit = make(QLineEdit)

w = TextEditor()

w.content.setText("Alice")
w.undo("content")
assert w.record_observable_proxy.observable(str, "content").get() == ""

w.redo("content")
assert w.record_observable_proxy.observable(str, "content").get() == "Alice"  # Restored

can_undo()

Check whether undo is available for a field.

def can_undo(self, field: str) -> bool

Parameters: - field: The field name

Returns: True if undo is available, False otherwise

Example:

@widget(undo=True)
class TextEditor(QWidget, Widget[Document]):
    content: QLineEdit = make(QLineEdit)
    undo_btn: QPushButton = make(QPushButton, "Undo", clicked="do_undo")

    def setup(self) -> None:
        # Update button state when undo availability changes
        self.record_observable_proxy.observable(str, "content").on_change(self.update_undo_btn)

    def update_undo_btn(self, _: str) -> None:
        self.undo_btn.setEnabled(self.can_undo("content"))

    def do_undo(self) -> None:
        if self.can_undo("content"):
            self.undo("content")

w = TextEditor()
assert w.can_undo("content") is False  # No history yet

w.content.setText("Alice")
assert w.can_undo("content") is True  # Can now undo

w.undo("content")
assert w.can_undo("content") is False  # No more undo

can_redo()

Check whether redo is available for a field.

def can_redo(self, field: str) -> bool

Parameters: - field: The field name

Returns: True if redo is available, False otherwise

Example:

@widget(undo=True)
class TextEditor(QWidget, Widget[Document]):
    content: QLineEdit = make(QLineEdit)
    redo_btn: QPushButton = make(QPushButton, "Redo", clicked="do_redo")

    def setup(self) -> None:
        self.record_observable_proxy.observable(str, "content").on_change(self.update_redo_btn)

    def update_redo_btn(self, _: str) -> None:
        self.redo_btn.setEnabled(self.can_redo("content"))

    def do_redo(self) -> None:
        if self.can_redo("content"):
            self.redo("content")

w = TextEditor()
assert w.can_redo("content") is False  # Nothing to redo

w.content.setText("Alice")
w.undo("content")
assert w.can_redo("content") is True  # Can now redo

Complete Example: Text Editor with Undo/Redo

from dataclasses import dataclass
from qtpy.QtWidgets import QWidget, QTextEdit, QPushButton
from qtpie import Widget, widget, make

@dataclass
class Document:
    content: str = ""

@widget(undo=True, undo_max=100, undo_debounce_ms=500)
class TextEditor(QWidget, Widget[Document]):
    content: QTextEdit = make(QTextEdit)
    undo_btn: QPushButton = make(QPushButton, "Undo", clicked="do_undo")
    redo_btn: QPushButton = make(QPushButton, "Redo", clicked="do_redo")

    def setup(self) -> None:
        # Update button states when content changes
        self.record_observable_proxy.observable(str, "content").on_change(self.update_buttons)
        self.update_buttons("")  # Initial state

    def update_buttons(self, _: str) -> None:
        self.undo_btn.setEnabled(self.can_undo("content"))
        self.redo_btn.setEnabled(self.can_redo("content"))

    def do_undo(self) -> None:
        if self.can_undo("content"):
            self.undo("content")

    def do_redo(self) -> None:
        if self.can_redo("content"):
            self.redo("content")

Save/Load Methods

Save and load methods let you transfer data between the proxy and model instances or dictionaries.

save_to()

Save the current proxy state to a model instance.

def save_to(self, target: T) -> None

Parameters: - target: The model instance to save to

Example:

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)

w = PersonEditor()

w.name.setText("Alice")
w.age.setValue(30)

# Save back to original model
w.save_to(w.record)
assert w.record.name == "Alice"
assert w.record.age == 30

# Save to a different instance
new_person = Person()
w.save_to(new_person)
assert new_person.name == "Alice"
assert new_person.age == 30

Common Pattern: Edit Form with Save/Cancel

@widget
class PersonEditor(QWidget, Widget[Person]):
    record: Person = make_later()  # Set externally

    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)
    save_btn: QPushButton = make(QPushButton, "Save", clicked="save")
    cancel_btn: QPushButton = make(QPushButton, "Cancel", clicked="cancel")

    original_model: Person | None = None

    def edit(self, person: Person) -> None:
        """Start editing a person."""
        self.original_model = person
        self.set_record(Person(name=person.name, age=person.age))  # Work on copy

    def save(self) -> None:
        """Save changes back to original."""
        if self.original_model:
            self.save_to(self.original_model)
            self.reset_dirty()
            self.close()

    def cancel(self) -> None:
        """Discard changes."""
        if self.is_dirty():
            # Show confirmation dialog...
            pass
        self.close()

load_dict()

Load data from a dictionary into the proxy.

def load_dict(self, data: dict[str, Any]) -> None

Parameters: - data: Dictionary of field names to values

Example:

@widget
class PersonEditor(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)

w = PersonEditor()

# Load from dictionary (e.g., from JSON API)
w.load_dict({"name": "Charlie", "age": 25})

assert w.name.text() == "Charlie"
assert w.age.value() == 25

Common Pattern: Load from API

@widget
class UserEditor(QWidget, Widget[User]):
    name: QLineEdit = make(QLineEdit)
    email: QLineEdit = make(QLineEdit)
    load_btn: QPushButton = make(QPushButton, "Load User", clicked="load_user")

    def load_user(self) -> None:
        # Fetch from API
        response = api.get_user(user_id=123)
        data = response.json()  # {"name": "Alice", "email": "alice@example.com"}

        # Load into form
        self.load_dict(data)
        self.reset_dirty()  # Mark as clean since we just loaded

Lifecycle Hook

Override setup() to customize initialization after fields are ready:

@widget
class MyWidget(QWidget, Widget[Person]):
    name: QLineEdit = make(QLineEdit)

    def setup(self) -> None:
        """Called after widget initialization. Set up initial state."""
        pass

Complete Example: User Form

Here's a complete example combining validation, dirty tracking, and save/load:

from dataclasses import dataclass
from qtpy.QtWidgets import QWidget, QLineEdit, QSpinBox, QPushButton, QLabel
from qtpie import Widget, widget, make

@dataclass
class User:
    name: str = ""
    age: int = 0
    email: str = ""

@widget(undo=True)
class UserEditor(QWidget, Widget[User]):
    # Input fields
    name: QLineEdit = make(QLineEdit)
    age: QSpinBox = make(QSpinBox)
    email: QLineEdit = make(QLineEdit)

    # Error displays
    name_error: QLabel = make(QLabel)
    email_error: QLabel = make(QLabel)

    # Status and actions
    status: QLabel = make(QLabel)
    save_btn: QPushButton = make(QPushButton, "Save", clicked="save")
    undo_btn: QPushButton = make(QPushButton, "Undo", clicked="do_undo")
    redo_btn: QPushButton = make(QPushButton, "Redo", clicked="do_redo")

    def setup(self) -> None:
        # Add validation rules
        self.add_validator("name", lambda v: "Name required" if not v else None)
        self.add_validator("age", lambda v: "Must be 18+" if v < 18 else None)
        self.add_validator(
            "email",
            lambda v: "Invalid email" if v and "@" not in v else None
        )

        # Show validation errors
        self.validation_for("name").on_change(
            lambda errors: self.name_error.setText(", ".join(errors))
        )
        self.validation_for("email").on_change(
            lambda errors: self.email_error.setText(", ".join(errors))
        )

        # Enable save only when valid
        self.is_valid().on_change(lambda valid: self.save_btn.setEnabled(valid))

        # Show dirty status
        self.record_observable_proxy.is_dirty_observable().on_change(self.update_status)

        # Update undo/redo buttons
        self.record_observable_proxy.observable(str, "name").on_change(self.update_undo_buttons)

    def update_status(self, dirty: bool) -> None:
        if dirty:
            self.status.setText("* Unsaved changes")
        else:
            self.status.setText("Saved")

    def update_undo_buttons(self, _: str) -> None:
        self.undo_btn.setEnabled(self.can_undo("name"))
        self.redo_btn.setEnabled(self.can_redo("name"))

    def save(self) -> None:
        if self.is_valid().get():
            self.save_to(self.record)
            self.reset_dirty()
            print(f"Saved: {self.record}")

    def do_undo(self) -> None:
        if self.can_undo("name"):
            self.undo("name")

    def do_redo(self) -> None:
        if self.can_redo("name"):
            self.redo("name")

See Also