Skip to content

Forms

Forms are where most desktop apps start: fields, buttons, rules and messages. Pyzed gives you two ways to make them, and you can mix both in one app:

  • Draw them in Pyzed Design, the visual designer: drag controls onto a window, set their properties, and write only the Python that runs when something happens (a .pzform file plus a .py file with the handlers). If you have used Visual Basic, Qt Designer or the editors of Godot and Unreal Engine, it will feel familiar.
  • Write them in Python with the components library: ui.field, ui.input, ui.select, ui.switch… in a view() method.

Drawn in the designer: an email and a password, a “remember me” check box, and a button that shows a progress bar while it “signs in”. The Python file is just four small functions, one per event.

A sign-in form running: email, password, remember me and a Sign in button
  • Controls are attributes of form: form.email.text, form.status.color = "error", form.progress.show().
  • A Timer control (ticker) calls ticker_tick every few milliseconds while it runs; that is how the progress bar fills up without blocking the window.
Windows, macOS and Linux
uv run --with pyzed-ui pyzed run examples/designer/login.pzform
Event handlers: examples/designer/login.py
examples/designer/login.py
"""Event handlers for the login form.
Each function receives the running ``form`` (controls are attributes: ``form.button1.text``)
and an ``event`` with ``event.sender``, ``event.name`` and ``event.value``.
"""
def sign_in_click(form, event):
"""Runs when sign_in is clicked."""
if "@" not in form.email.text:
form.status.text = "Enter a valid email address."
form.status.color = "error"
return form.email.focus()
form.status.text = ""
form.sign_in.enabled = False
form.progress.value = 0
form.progress.show()
form.ticker.running = True
def ticker_tick(form, event):
"""Runs every ``interval`` milliseconds while ticker is enabled."""
form.progress.value += 4
if form.progress.value >= 100:
form.ticker.running = False
form.progress.hide()
form.status.color = "success"
form.status.text = f"Signed in as {form.email.text}"
form.sign_in.enabled = True
def password_submit(form, event):
"""Runs when Enter is pressed in password."""
return sign_in_click(form, event)
def form_load(form, event):
"""Runs once when login opens."""
form.status.text = "Tip: any email works in this demo."
return form.email.focus()

Every field is bound to form.data and has rules set in the Properties panel (required, an email, at least 8 characters, the same as another field). One line, form.validate(), checks them all and shows the messages next to the fields; the greeting and the summary update by themselves as you type.

A sign-up form with error messages next to two fields
Windows, macOS and Linux
uv run --with pyzed-ui pyzed run examples/tutorial/designer/signup.pzform
Event handlers: examples/tutorial/designer/signup.py
examples/tutorial/designer/signup.py
"""Event handlers for the sign-up form (tutorial: Data binding and validation).
pyzed run examples/tutorial/designer/signup.pzform
The controls are bound to fields of ``form.data`` (Properties → Data → Bind to) and carry their
validation rules, so the handler only has to call ``form.validate()`` and read ``form.data``.
"""
def create_click(form, event):
"""Runs when create is clicked."""
if not form.validate(): # checks every rule and shows the messages under the controls
form.status.color = "error"
form.status.text = f"{len(form.errors)} field(s) need your attention."
return
data = form.data # {"name": ..., "email": ..., "password": ..., "age": ..., "terms": True}
print("New account:", {k: v for k, v in data.items() if k != "password"})
form.status.color = "success"
form.status.text = f"Welcome aboard, {data['name']}!"

Built step by step in Data binding and validation.

Name, email, a topic drop-down and a message with a live character counter. The Send button checks the fields and writes the message to the console.

A contact form with a topic drop-down and a character counter
Windows, macOS and Linux
uv run --with pyzed-ui pyzed run examples/tutorial/designer/contact.pzform
Event handlers: examples/tutorial/designer/contact.py
examples/tutorial/designer/contact.py
"""Event handlers for the contact form (tutorial: A form with inputs and a button).
pyzed run examples/tutorial/designer/contact.pzform
Each function receives the running ``form`` and an ``event`` (``event.value`` holds the new
value of a ``change`` event).
"""
LIMIT = 500
def form_load(form, event):
"""Runs once when contact opens: put the cursor in the first box."""
return form.your_name.focus()
def message_change(form, event):
"""Runs on every keystroke in message: update the character counter."""
count = len(event.value)
form.counter.text = f"{count} / {LIMIT}"
form.counter.color = "error" if count > LIMIT else "text_muted"
def send_click(form, event):
"""Runs when send is clicked: check the fields, then 'send' the message."""
if not form.your_name.text.strip():
form.status.color = "error"
form.status.text = "Please tell us your name."
return form.your_name.focus()
if "@" not in form.email.text:
form.status.color = "error"
form.status.text = "That email does not look right."
return form.email.focus()
if len(form.message.text) > LIMIT:
form.status.color = "error"
form.status.text = f"Keep the message under {LIMIT} characters."
return None
# print() shows up in the designer's console (and in the terminal)
print(f"New {form.topic.selected.lower()} from {form.email.text}: {form.message.text!r}")
form.status.color = "success"
form.status.text = f"Thanks, {form.your_name.text.split()[0]}! We got your message."
form.message.text = ""
form.counter.text = f"0 / {LIMIT}"
return None

Built step by step in A form that does something.

Two profile cards, each with a five-star Rating component, side by side in a flex row that grows and shrinks with the window. Rating, ProfileCard and TeamCard are components drawn in the designer’s component mode (.pzcomp files): they have their own properties, events and code, and TeamCard inherits everything from ProfileCard.

Two profile cards with star ratings
Windows, macOS and Linux
uv run --with pyzed-ui pyzed run examples/designer/components/reviews.pzform
Event handlers: examples/designer/components/reviews.py
examples/designer/components/reviews.py
"""Event handlers for the Reviews form.
Each function receives the running ``form`` (controls are attributes: ``form.button1.text``)
and an ``event`` with ``event.sender``, ``event.name`` and ``event.value``.
"""
def ada_rated(form, event):
"""Runs when the card of ada is rated."""
event.sender.score = event.value
form.status.text = f"{event.sender.person} now has {event.value} stars"
def grace_rated(form, event):
"""Runs when the card of grace is rated."""
event.sender.score = event.value
form.status.text = f"{event.sender.person} now has {event.value} stars"

Built step by step in A custom component.

A full sign-in screen written in code: a brand panel, single sign-on buttons, a passkey option and an email form. It is one of the Blocks, screens you can copy into your app and adapt.

A split login screen with a brand panel and a sign-in formA split login screen with a brand panel and a sign-in form
Windows, macOS and Linux
uv run --with pyzed-ui python examples/blocks/login.py

The code and what to change in it: Login block.

A multi-step setup: a stepper at the top, choice cards, per-step validation and a summary before finishing.

A setup wizard with a stepper and choice cardsA setup wizard with a stepper and choice cards
Windows, macOS and Linux
uv run --with pyzed-ui python examples/blocks/onboarding.py

The code: Onboarding block.

Sections on the left, grouped rows on the right: profile fields, a language select, light, dark or system theme, accent swatches, a font size slider, notification switches, and a danger zone that asks before deleting.

A settings page with sections, switches and theme optionsA settings page with sections, switches and theme options
Windows, macOS and Linux
uv run --with pyzed-ui python examples/blocks/settings.py

The code: Settings block.

Each button opens one of the ready-made dialogs of pyzed.ui (an alert, a confirmation, a prompt, a choice and “unsaved changes”). Pick a language in the toolbar and every text, including the dialogs’ buttons, changes while the app runs.

The confirmation dialog: Are you sure? Delete 3 files? with Cancel and Delete
Windows, macOS and Linux
uv run --with pyzed-ui python examples/dialogs.py
Full code: examples/dialogs.py
examples/dialogs.py
"""Dialogs and languages: ready-made dialogs and switching the app's language while it runs.
python examples/dialogs.py
- Pick a language in the toolbar; every text (and the dialogs' buttons) follows it.
- Each button opens one of the dialogs in ``pyzed.ui``.
"""
import pyzed as pz
from pyzed import i18n, ui
i18n.add("en", {
"title": "Dialogs",
"language": "Language",
"files": {"zero": "No files", "one": "{count} file", "other": "{count} files"},
"actions": {"alert": "Show an alert", "confirm": "Delete files", "prompt": "Rename", "choice": "Export",
"unsaved": "Close document"},
"delete_question": "Delete {files}? This cannot be undone.",
"saved": "Your changes were saved.",
"rename": "Rename file",
"new_name": "New name",
"export": "Export the report as…",
"last": "Last action: {action}",
})
i18n.add("es", {
"title": "Diálogos",
"language": "Idioma",
"files": {"zero": "Ningún archivo", "one": "{count} archivo", "other": "{count} archivos"},
"actions": {"alert": "Mostrar un aviso", "confirm": "Eliminar archivos", "prompt": "Renombrar", "choice": "Exportar",
"unsaved": "Cerrar documento"},
"delete_question": "¿Eliminar {files}? No se puede deshacer.",
"saved": "Tus cambios se guardaron.",
"rename": "Renombrar archivo",
"new_name": "Nombre nuevo",
"export": "Exportar el informe como…",
"last": "Última acción: {action}",
})
LANGUAGES = {"en": "English", "es": "Español"}
class DialogsDemo(pz.App):
size = (720, 460)
def __init__(self):
self.dialog = None
self.files = 3
self.name = "report.md"
self.last = "-"
def title(self):
return pz.t("title")
def open(self, kind):
self.dialog = kind
def done(self, action):
self.last = action
self.dialog = None
def delete(self):
self.files = 0
self.done("delete")
def dialog_view(self):
close = lambda: self.done("cancel") # noqa: E731
if self.dialog == "alert":
return ui.alert(pz.t("saved"), close, kind="success")
if self.dialog == "confirm":
return ui.confirm(pz.t("delete_question", files=pz.t("files", count=self.files)), self.delete, close,
danger=True)
if self.dialog == "prompt":
return ui.prompt(pz.t("rename"), self.name, lambda v: setattr(self, "name", v),
lambda: self.done(f"rename → {self.name}"), close, message=pz.t("new_name"))
if self.dialog == "choice":
return ui.choice(pz.t("export"), [(f, lambda f=f: self.done(f"export {f}")) for f in ("CSV", "PDF")], close)
if self.dialog == "unsaved":
return ui.unsaved_changes(self.name, lambda: self.done("save"), lambda: self.done("discard"), close)
return None
def view(self):
toolbar = pz.row([
ui.heading(pz.t("title")),
pz.space(width="fill"),
ui.label(pz.t("language"), color="text_muted"),
ui.segmented([LANGUAGES[k] for k in LANGUAGES], LANGUAGES[i18n.language()[:2]],
lambda name: i18n.set_language(next(k for k, v in LANGUAGES.items() if v == name))),
], align="center", spacing=10)
buttons = pz.grid(
[ui.button(pz.t(f"actions.{kind}"), lambda kind=kind: self.open(kind), full_width=True)
for kind in ("alert", "confirm", "prompt", "choice", "unsaved")],
columns=["1fr", "1fr"], spacing=8,
)
body = pz.column([
toolbar,
ui.label(f"{self.name} · {pz.t('files', count=self.files)}", color="text_muted"),
buttons,
ui.label(pz.t("last", action=self.last), mono=True, size="sm"),
], spacing=16, padding=24)
return ui.dialog_layer(body, self.dialog_view(), on_dismiss=lambda: self.done("cancel"))
if __name__ == "__main__":
pz.run(DialogsDemo)

For field errors, confirmations and toasts in your own forms, see Dialogs, notifications and validation.

The rules of this form live in a Pydantic model, the same kind of model a web API would use. The window keeps what the user types as plain text and hands it to SignUp.model_validate; Pydantic converts the values (a date, an email, a yes/no), checks every rule, and pz.validation_errors turns its errors into a message per field. Accounts are saved to accounts.json.

The sign-up form with a message under each invalid field, and the saved account as JSON

It is a small project with its own dependencies, so run it from its folder:

Windows, macOS and Linux
cd examples/integrations/pydantic_signup
uv run main.py
Full code: main.py and models.py
examples/integrations/pydantic_signup/models.py
"""The form's rules, written as Pydantic models (https://docs.pydantic.dev/latest/concepts/models/)."""
from __future__ import annotations
from datetime import date
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, SecretStr, TypeAdapter, field_validator, model_validator
class SignUp(BaseModel):
username: str = Field(min_length=3, max_length=20, pattern=r"^[a-z0-9_]+$")
email: EmailStr
birthday: date
plan: Literal["free", "pro", "team"] = "free"
password: SecretStr # repr() and str() show ********
password_repeat: SecretStr
newsletter: bool = False
@field_validator("username", mode="before")
@classmethod
def lowercase(cls, value: str) -> str:
return value.strip().lower()
@field_validator("birthday")
@classmethod
def old_enough(cls, value: date) -> date:
today = date.today()
age = today.year - value.year - ((today.month, today.day) < (value.month, value.day))
if age < 13:
raise ValueError("You must be at least 13 years old")
return value
@field_validator("password")
@classmethod
def long_enough(cls, value: SecretStr) -> SecretStr:
if len(value.get_secret_value()) < 8:
raise ValueError("Use 8 characters or more")
return value
@model_validator(mode="after")
def passwords_match(self) -> SignUp:
if self.password.get_secret_value() != self.password_repeat.get_secret_value():
raise ValueError("The passwords don't match")
return self
class Account(BaseModel):
"""What gets saved: the sign-up without the passwords."""
username: str
email: EmailStr
birthday: date
plan: Literal["free", "pro", "team"]
newsletter: bool
Accounts = TypeAdapter(list[Account]) # validates and serializes a whole list at once
examples/integrations/pydantic_signup/main.py
"""A sign-up form whose rules live in a Pydantic model; accounts are saved to accounts.json.
uv run main.py
The window keeps what the user types as plain strings and hands them to
``SignUp.model_validate``: Pydantic converts them (a date, an email, a bool), checks every rule
and reports the errors per field, which ``pz.validation_errors`` turns into ``{field: message}``.
Walkthrough: https://albakore.github.io/pyzed/integrations/pydantic/
"""
from __future__ import annotations
from pathlib import Path
from pydantic import ValidationError
import pyzed as pz
from pyzed import ui
from models import Account, Accounts, SignUp
FILE = Path(__file__).with_name("accounts.json")
EMPTY = {"username": "", "email": "", "birthday": "", "plan": "free", "password": "", "password_repeat": "",
"newsletter": False}
def load() -> list[Account]:
return Accounts.validate_json(FILE.read_bytes()) if FILE.exists() else []
class SignUpForm(pz.App):
title = "Sign up · Pydantic"
size = (860, 600)
def __init__(self):
self.draft = dict(EMPTY)
self.errors: dict[str, str] = {}
self.accounts = load()
self.preview = "" # the JSON Pydantic produced for the last account
def edit(self, key):
return lambda value: self.draft.__setitem__(key, value)
def submit(self, *_):
try:
signup = SignUp.model_validate(self.draft)
except ValidationError as error:
self.errors = pz.validation_errors(error) # the model validator's error has the key ""
return
account = Account.model_validate(signup.model_dump(exclude={"password", "password_repeat"}))
if any(a.username == account.username for a in self.accounts):
self.errors = {"username": "That username is taken"}
return
self.accounts.append(account)
FILE.write_bytes(Accounts.dump_json(self.accounts, indent=2))
self.preview = account.model_dump_json(indent=2)
self.draft, self.errors = dict(EMPTY), {}
def form(self):
e, d = self.errors.get, self.draft
def text(key, title, placeholder="", **kwargs):
return ui.field(title, ui.input(d[key], self.edit(key), placeholder=placeholder, on_submit=self.submit,
**kwargs), error=e(key))
return pz.column([
ui.heading("Create an account", level=2),
text("username", "Username", "lowercase letters, digits and _"),
text("email", "Email", "you@example.com"),
text("birthday", "Birthday", "YYYY-MM-DD"),
ui.field("Plan", ui.segmented(["free", "pro", "team"], d["plan"], self.edit("plan"))),
text("password", "Password", "8 characters or more", secure=True),
text("password_repeat", "Repeat the password", secure=True),
ui.checkbox(d["newsletter"], self.edit("newsletter"), label="Send me the newsletter"),
ui.callout(e(""), tint="error") if e("") else None,
ui.button("Sign up", self.submit, icon="user-plus", variant="solid", full_width=True),
], spacing=12, padding=24, width=420)
def side(self, t):
rows = [ui.list_item(a.username, None, icon="user", end=ui.badge(a.plan), inset=True, height=30)
for a in reversed(self.accounts)]
return pz.container(pz.column([
ui.muted("accounts.json", size="xs", mono=True),
*rows,
ui.card(ui.label(self.preview, mono=True, size="sm"), title="model_dump_json()") if self.preview else None,
], spacing=6), padding=16, width="fill", height="fill", bg=t.panel)
def view(self):
t = self.resolved_theme()
return pz.row([pz.container(pz.scrollable(self.form()), height="fill", bg=t.editor), self.side(t)])
if __name__ == "__main__":
pz.run(SignUpForm)

The walkthrough is in Pydantic.