# Formularios

> Formularios de escritorio en Python hechos con Pyzed: inicio de sesión, registro con validación, un formulario de contacto, pantallas de bienvenida y de ajustes, diálogos, validación con Pydantic y formularios dibujados en el diseñador visual Pyzed Design.

Source: https://albakore.github.io/pyzed/es/showcase/forms/

Los formularios son el punto de partida de la mayoría de las apps de escritorio: campos, botones, reglas y mensajes.
Pyzed te da dos formas de hacerlos, y puedes mezclar las dos en una misma app:

- **Dibujarlos** en [Pyzed Design](https://albakore.github.io/pyzed/es/editor/), el diseñador visual: arrastras controles a una ventana,
  configuras sus propiedades y escribes solo el Python que corre cuando pasa algo (un archivo `.pzform` más un `.py`
  con los manejadores). Si usaste Visual Basic, Qt Designer o los editores de Godot y Unreal Engine, te resultará
  familiar.
- **Escribirlos** en Python con la [biblioteca de componentes](https://albakore.github.io/pyzed/es/library/): `ui.field`, `ui.input`,
  `ui.select`, `ui.switch`... en un método `view()`.

**Abre un formulario en el diseñador:**

Cada `.pzform` de esta página se abre en Pyzed Design: `pyzed design ruta/al/formulario.pzform`. Pulsa
<kbd>F5</kbd> allí para ejecutarlo y mira el panel **Eventos** para ver qué manejador llama cada control.

## Formulario de acceso

Dibujado en el diseñador: un correo y una contraseña, una casilla "recordarme" y un botón que muestra una barra de
progreso mientras "inicia sesión". El archivo de Python son solo cuatro funciones pequeñas, una por evento.

- Los controles son atributos de `form`: `form.email.text`, `form.status.color = "error"`, `form.progress.show()`.
- Un control **Temporizador** (`ticker`) llama a `ticker_tick` cada pocos milisegundos mientras corre; así se llena
  la barra de progreso sin bloquear la ventana.

<details>
<summary>Manejadores de eventos: <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>

## Registro con reglas

Cada campo está **enlazado** a `form.data` y tiene **reglas** definidas en el panel Propiedades (obligatorio, un
correo, al menos 8 caracteres, igual que otro campo). Una línea, `form.validate()`, las revisa todas y muestra los
mensajes junto a los campos; el saludo y el resumen se actualizan solos mientras escribes.

<details>
<summary>Manejadores de eventos: <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>

Se construye paso a paso en [Enlace de datos y validación](https://albakore.github.io/pyzed/es/tutorials/intermediate/data-binding/).

## Formulario de contacto

Nombre, correo, un desplegable de tema y un mensaje con un contador de caracteres en vivo. El botón **Send** revisa
los campos y escribe el mensaje en la consola.

<details>
<summary>Manejadores de eventos: <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>

Se construye paso a paso en [Un formulario que hace algo](https://albakore.github.io/pyzed/es/tutorials/basic/first-form/).

## Valoraciones con componentes

Dos tarjetas de perfil, cada una con un componente **Rating** de cinco estrellas, una al lado de la otra en una
fila flexible que crece y se encoge con la ventana. `Rating`, `ProfileCard` y `TeamCard` son componentes dibujados
en el modo componente del diseñador (archivos `.pzcomp`): tienen sus propias propiedades, eventos y código, y
`TeamCard` hereda todo de `ProfileCard`.

<details>
<summary>Manejadores de eventos: <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>

Se construye paso a paso en [Un componente propio](https://albakore.github.io/pyzed/es/tutorials/intermediate/custom-component/).

## Pantalla de login

Una pantalla de inicio de sesión completa escrita en código: un panel de marca, botones de inicio de sesión único,
una opción de passkey y un formulario de correo. Es uno de los [Bloques](https://albakore.github.io/pyzed/es/blocks/), pantallas que puedes
copiar en tu app y adaptar.

El código y qué cambiar en él: [bloque Login](https://albakore.github.io/pyzed/es/blocks/login/).

## Asistente de bienvenida

Una configuración en varios pasos: una barra de pasos arriba, tarjetas para elegir, validación en cada paso y un
resumen antes de terminar.

El código: [bloque Onboarding](https://albakore.github.io/pyzed/es/blocks/onboarding/).

## Ajustes

Secciones a la izquierda y filas agrupadas a la derecha: campos de perfil, un selector de idioma, tema claro,
oscuro o del sistema, muestras de color de acento, un deslizador de tamaño de letra, interruptores de
notificaciones y una zona de peligro que pregunta antes de borrar.

El código: [bloque Settings](https://albakore.github.io/pyzed/es/blocks/settings/).

## Diálogos e idiomas

Cada botón abre uno de los diálogos listos de `pyzed.ui` (un aviso, una confirmación, una pregunta, una elección y
"cambios sin guardar"). Elige un idioma en la barra de herramientas y cada texto, incluidos los botones de los
diálogos, cambia mientras la app corre.

<details>
<summary>Código completo: <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>

Para errores de campo, confirmaciones y toasts en tus propios formularios, mira
[Diálogos, notificaciones y validación](https://albakore.github.io/pyzed/es/tutorials/intermediate/dialogs-feedback/).

## Registro validado con Pydantic

Las reglas de este formulario viven en un modelo de [Pydantic](https://docs.pydantic.dev), el mismo tipo de modelo
que usaría una API web. La ventana guarda lo que escribe el usuario como texto y se lo pasa a
`SignUp.model_validate`; Pydantic convierte los valores (una fecha, un correo, un sí/no), revisa cada regla, y
`pz.validation_errors` convierte sus errores en un mensaje por campo. Las cuentas se guardan en `accounts.json`.

Es un pequeño proyecto con sus propias dependencias, así que ejecútalo desde su carpeta:

**uv:**

```bash title="Windows, macOS y 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="Con pyzed-ui y pydantic[email] ya instalados"
cd examples/integrations/pydantic_signup
python main.py
```

<details>
<summary>Código completo: <code>main.py</code> y <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>

La explicación paso a paso está en [Pydantic](https://albakore.github.io/pyzed/es/integrations/pydantic/).

**Sobre esta página:**

Los formularios del diseñador y las apps de esta página se cargan y se dibujan en la suite de tests con la versión
actual de Pyzed. Vienen grabaciones de cómo se rellenan.
