# Forms

> Python desktop forms made with Pyzed: sign-in, sign-up with validation, a contact form, onboarding and settings screens, dialogs, Pydantic validation and forms drawn in the Pyzed Design visual designer.

Source: https://albakore.github.io/pyzed/showcase/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](https://albakore.github.io/pyzed/editor/), 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](https://albakore.github.io/pyzed/library/): `ui.field`, `ui.input`, `ui.select`,
  `ui.switch`... in a `view()` method.

**Open a form in the designer:**

Every `.pzform` on this page opens in Pyzed Design: `pyzed design path/to/form.pzform`. Press <kbd>F5</kbd> there
to run it, and look at the **Events** panel to see which handler each control calls.

## Sign-in form

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.

- 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.

<details>
<summary>Event handlers: <code>examples/designer/login.py</code></summary>
`examples/designer/login.py`:

```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()
```
</details>

## Sign-up with validation

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.

<details>
<summary>Event handlers: <code>examples/tutorial/designer/signup.py</code></summary>
`examples/tutorial/designer/signup.py`:

```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']}!"
```
</details>

Built step by step in [Data binding and validation](https://albakore.github.io/pyzed/tutorials/intermediate/data-binding/).

## Contact form

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.

<details>
<summary>Event handlers: <code>examples/tutorial/designer/contact.py</code></summary>
`examples/tutorial/designer/contact.py`:

```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
```
</details>

Built step by step in [A form that does something](https://albakore.github.io/pyzed/tutorials/basic/first-form/).

## Reviews with custom components

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`.

<details>
<summary>Event handlers: <code>examples/designer/components/reviews.py</code></summary>
`examples/designer/components/reviews.py`:

```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"
```
</details>

Built step by step in [A custom component](https://albakore.github.io/pyzed/tutorials/intermediate/custom-component/).

## Login screen

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](https://albakore.github.io/pyzed/blocks/), screens you can copy into your app and adapt.

The code and what to change in it: [Login block](https://albakore.github.io/pyzed/blocks/login/).

## Onboarding wizard

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

The code: [Onboarding block](https://albakore.github.io/pyzed/blocks/onboarding/).

## Settings page

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.

The code: [Settings block](https://albakore.github.io/pyzed/blocks/settings/).

## Dialogs and languages

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.

<details>
<summary>Full code: <code>examples/dialogs.py</code></summary>
`examples/dialogs.py`:

```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)
```
</details>

For field errors, confirmations and toasts in your own forms, see
[Dialogs, notifications and validation](https://albakore.github.io/pyzed/tutorials/intermediate/dialogs-feedback/).

## Sign-up validated by Pydantic

The rules of this form live in a [Pydantic](https://docs.pydantic.dev) 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`.

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

**uv:**

```bash title="Windows, macOS and Linux"
cd examples/integrations/pydantic_signup
uv run main.py
```

**pip:**

```bash title="macOS / Linux"
cd examples/integrations/pydantic_signup
python3 -m venv .venv
source .venv/bin/activate
python -m pip install pyzed-ui "pydantic[email]>=2.6"
python main.py
```

```powershell title="Windows (PowerShell)"
cd examples\integrations\pydantic_signup
py -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install pyzed-ui "pydantic[email]>=2.6"
python main.py
```

**python:**

```bash title="With pyzed-ui and pydantic[email] already installed"
cd examples/integrations/pydantic_signup
python main.py
```

<details>
<summary>Full code: <code>main.py</code> and <code>models.py</code></summary>
`examples/integrations/pydantic_signup/models.py`:

```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`:

```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)
```
</details>

The walkthrough is in [Pydantic](https://albakore.github.io/pyzed/integrations/pydantic/).

**About this page:**

The designer forms and the apps on this page are loaded and drawn by the test suite with the current version
of Pyzed. Recordings of them being
filled in are on the way.
