Añadir un componente al editor
Esta guía explica cómo añadir un control nuevo a Pyzed Design, el editor visual de
python/pyzed/designer/, de principio a fin. El ejemplo es un control Rating (valoración): una fila de
estrellas que lanza un evento change con el número de la estrella pulsada.

El recorrido de un componente
Sección titulada «El recorrido de un componente»Todo lo que el editor sabe de un control sale de un registro Component en
python/pyzed/designer/components.py. La misma función render dibuja el control en el lienzo (inerte) y en
la aplicación en ejecución (activo), así que lo que diseñas es exactamente lo que se ejecuta.
flowchart LR
UI["función de pyzed.ui<br/>(ui/*.py)"] --> SPEC["designer.Component<br/>+ Prop, eventos"]
SPEC -->|register| REG["registro<br/>categories()"]
REG --> TB["Caja de herramientas<br/>(panels.toolbox)"]
TB -->|arrastrar / Document.add| CV["Lienzo<br/>RenderContext (inerte)"]
CV --> PG["Propiedades<br/>editores según Prop.kind"]
CV -->|Document.save| PZ[".pzform<br/>props + events"]
CV -->|Document.wire| PY["handlers .py<br/>stub control_evento()"]
PZ --> RT["FormApp / pyzed run<br/>LiveContext"]
PY --> RT
| Etapa | Código | Qué pasa |
|---|---|---|
| Widget | python/pyzed/ui/ |
Una función normal devuelve un Widget construido con pyzed._core (Rust). |
| Entrada de la caja | designer/components.py |
Component(...) describe el tipo, la etiqueta, el icono, la categoría, el tamaño, las propiedades y los eventos; register() lo añade. |
| Caja de herramientas | designer/panels.py |
toolbox() lista categories() con el icon y el label de cada entrada (traducido con tr) y su description como tooltip. |
| Lienzo | designer/surface.py |
Dibuja cada control con components.render(control, RenderContext(...)); los callbacks son inertes. |
| Propiedades | designer/panels.py |
Un editor por cada Prop, elegido por su kind y agrupado por su group. |
| Guardado | designer/model.py |
Control.to_dict() escribe type, la geometría, props y events en el JSON del .pzform. |
| Handlers | designer/document.py, designer/code.py |
Document.wire(nombre, evento) llama al handler <control>_<evento> y añade un stub al archivo .py. |
| Ejecución | designer/runtime.py |
LiveContext.on() guarda el valor nuevo en la propiedad y llama al handler conectado con (form, event). |
1. El componente de pyzed.ui
Sección titulada «1. El componente de pyzed.ui»La mayoría de los controles del diseñador envuelven una función de pyzed.ui, así que el control también
está disponible para quien escribe su interfaz en código. Ponla en el módulo que corresponda (inputs.py,
controls.py, display.py…) y sigue las convenciones del paquete:
- Es una función normal que devuelve un
Widget; primero el valor, después el callback y luego las opciones solo por nombre. - El último parámetro es
theme: Optional[Theme] = None, que se resuelve cont = _theme(theme); todos los colores salen del tema (t.color("accent"),t.text_muted,t.ghost_hover…), nunca un hexadecimal fijo. - Los callbacks reciben el valor nuevo; sin callback, el control es de solo lectura.
- Un docstring de una línea que diga qué dibuja y, después, qué recibe el callback.
def rating( value: float, on_change: Any = None, *, max: int = 5, # noqa: A002 - mirrors ui.progress size: float = 16, tint: str = "accent", theme: Optional[Theme] = None,) -> _core.Widget: """A row of ``max`` stars with the first ``value`` lit, in the ``tint`` color.
``on_change`` is called with the number of the clicked star (1 to ``max``); without it the stars are read only. """ t = _theme(theme) stars = [] for i in range(1, int(max) + 1): lit = i <= value star = _icon("star", size=size, color=t.color(tint) if lit else alpha(t.text_muted, 0.5)) stars.append(_core.button(star, (lambda i=i: on_change(i)) if on_change else None, padding=2, bg="transparent", hover_bg=t.ghost_hover, radius=t.radius_sm)) return _core.row(stars, spacing=0, align="center")controls.py ya importa _core, _icon, alpha, Theme y _theme. Después, expórtala en
python/pyzed/ui/__init__.py: añade rating a la lista from .controls import (...) y a __all__.
Todo nombre de __all__ debe estar documentado (mira
Documentar un componente).
Cuando el widget necesita Rust
Sección titulada «Cuando el widget necesita Rust»Solo tocas src/ cuando ninguna pieza existente puede dibujar el control: un widget nuevo de Iced, una
interacción nativa o algo que tiene que ser rápido. pyzed._core expone un conjunto pequeño de primitivas
(text, row, column, container, button, text_input, slider, progress_bar, mouse_area, stack,
svg, code_editor…), y la mayoría de los componentes de ui, como Rating, son combinaciones de ellas.
Un widget nativo se conecta en cuatro sitios. Así lo hace progress_bar:
-
Una variante de
Kindensrc/widget.rs, con datos de Rust normales (el árbol de widgets es inmutable y vive en Rust):pub enum Kind {// ...ProgressBar { min: f32, max: f32, value: f32, width: Option<Length>,height: Option<Length>, style: ToggleStyle, radius: f32 },} -
Una función
#[pyfunction]ensrc/widget.rs(osrc/extra.rs) que convierte los valores de Python con las ayudas desrc/style.rsy devuelvenew(py, Kind::...). Los callbacks se guardan comoPyMsg(PyMsg::maybe(&on_change)):#[pyfunction]#[pyo3(signature = (value, *, min=0.0, max=1.0, width=None, height=None, accent=None, bg=None, radius=2.0))]pub fn progress_bar(py: Python<'_>, value: f32, min: f32, max: f32, width: Any<'_>, height: Any<'_>,accent: Any<'_>, bg: Any<'_>, radius: f32) -> PyResult<Py<Widget>> {new(py, Kind::ProgressBar { min, max, value,width: style::opt_length(&width)?, height: style::opt_length(&height)?,style: toggle_style(&accent, &bg, &None, &None, &None)?, radius })} -
El registro en la función
register()del módulo (widget::register, que llamasrc/lib.rs):m.add_function(wrap_pyfunction!(progress_bar, m)?)?;. A partir de ahí espyzed._core.progress_bar. -
Una rama del
matchenbuild()desrc/render.rsque convierte elKinden uniced::Elementen cada frame. Las interacciones se convierten en unMessage(Message::Py(msg),Message::Slide(msg, value),Message::Toggle…), quesrc/app.rstraduce en una llamada al callback de Python.
Recompila con maturin develop --release, ejecuta cargo fmt y cargo clippy -- -D warnings, y envuelve la
primitiva nueva en una función de pyzed.ui como antes. Cómo funciona
explica el puente con más detalle.
2. La entrada de la caja de herramientas
Sección titulada «2. La entrada de la caja de herramientas»Los controles integrados viven en python/pyzed/designer/components.py: una función de dibujo y una entrada
en la tupla que se registra al final del módulo.
La función de dibujo
Sección titulada «La función de dibujo»def _rating(c: Control, ctx: RenderContext) -> Any: p = c.props return _fill(rating(_num(p["value"]), ctx.on(c, "change", "value") if c.enabled else None, max=int(_num(p["max"], 5)), size=_num(p["size"], 16), tint=p["tint"], theme=ctx.theme), c)(from ..ui.controls import rating, o llámala como ui.rating cuando esté exportada).
ces elControlque se dibuja.c.propsya tiene todas las propiedades:components.render()mezcla los valores por defecto del componente debajo de los guardados, así quep["max"]nunca lanzaKeyError, ni siquiera en formularios guardados antes de que existiera la propiedad.- Los valores del panel de propiedades o del JSON pueden ser textos o
None. Conviértelos con las ayudas del módulo:_num(valor, por_defecto),_lines(valor)para las propiedadeslist,_align(valor). - La función recibe el tamaño del control: rellénalo (
_fill(widget, c)envuelve el widget en un contenedorwidth="fill", height="fill", alineado al inicio y centrado en vertical). - Usa
ctx.on(c, evento, prop)para cada callback. En el diseñador devuelve una función inerte. En ejecución (LiveContext) guarda el argumento del callback enc.props[prop]y después llama al handler conectado aevento, con ese valor comoevent.value. PasaNonecuando el control está deshabilitado (c.enabled). ctx.themees el tema del formulario;ctx.liveesFalseen el lienzo (úsalo para dibujar ayudas de diseño, como hacen los contenedores de layout);ctx.key(c)es un id estable para widgets con estado interno (campos de texto, editores).- No dibujes tú el estilo del usuario:
components.render()aplica la sección Estilo del control (designer/styling.py) sobre lo que devuelves.
El registro Component
Sección titulada «El registro Component»Component( type="rating", label="Rating", icon="star", category="Common", render=_rating, size=(132, 28), props=(Prop("value", "number", 3, min=0, max=10, group="Data"), Prop("max", "number", 5, min=1, max=10, group="Data"), Prop("size", "number", 16, min=8, max=48), Prop("tint", "choice", "accent", choices=TINTS)), events=("change",), description="A row of stars to rate something; change gives the number of stars.",),Añádelo a la tupla for _component in (...), junto a los controles de su categoría; la caja de herramientas
muestra las categorías, y los componentes dentro de ellas, en el orden de registro.
| Campo | Significado |
|---|---|
type |
Id estable que se guarda en los .pzform y con el que se nombran los controles nuevos (rating1, rating2…). No lo cambies nunca después de publicarlo: los formularios antiguos dejarían de cargar (KeyError: unknown component type). |
label |
Nombre en la caja de herramientas y arriba del panel de propiedades. |
icon |
Un icono de Lucide, para la caja de herramientas y la estructura (pyzed icons los lista). |
category |
Sección de la caja: Common, Display, Containers, Layout, Navigation, Components o una nueva. |
render |
render(control, ctx) -> Widget, como arriba. |
size |
(ancho, alto) por defecto al soltarlo en el formulario. |
props |
Las propiedades del panel de propiedades, en orden (ver abajo). |
events |
Los eventos propios del control. El primero es el evento por defecto, que se abre con doble clic en el control. |
description |
Una frase; es el tooltip de la caja de herramientas y el subtítulo del panel de propiedades. |
visual |
False para controles que no dibujan nada en ejecución (como timer): no reciben eventos del ratón. |
container |
"free", "flex" o "grid" cuando se pueden soltar otros controles dentro (mira designer/layout.py). |
child_layout |
Opciones de layout para los controles nuevos de este tipo, como (("grow", 1),) en el espaciador. |
Propiedades
Sección titulada «Propiedades»Prop(name, kind, default, label="", choices=(), min=None, max=None, step=1.0, group="Appearance"):
kind |
Editor en el panel de propiedades | Valor |
|---|---|---|
text |
cuadro de texto de una línea | str |
multiline |
cuadro de texto de varias líneas | str |
number |
cuadro numérico con botones (min, max, step) |
número, o None si el valor por defecto es None |
bool |
interruptor | bool |
color |
muestra de color y selector | token del tema o hexadecimal ("" para ninguno) |
choice |
desplegable con choices |
str |
list |
editor de lista, un elemento por línea | str con un elemento por línea |
rows |
editor de tabla, filas separadas por comas, la primera es el encabezado | str |
icon |
selector de iconos de Lucide | nombre del icono ("" para ninguno) |
groupla coloca en una sección del panel:Appearance(por defecto),Data(los valores que cambia el usuario, comotext,value,selected),LayoutoBehavior.- El panel muestra
label, o el nombre con los guiones bajos como espacios (line_numbers→ Line numbers). - Llama a la propiedad que cambia con la interacción del usuario según lo que guarda (
value,checked,selected,text) y pasa ese nombre como tercer argumento dectx.on, asíform.rating1.valuesiempre está al día.
Eventos
Sección titulada «Eventos»- Usa los verbos existentes cuando encajen:
click,double_click,change,submit,release,tick.EVENT_DOCSencomponents.pyguarda el docstring del stub generado para cada evento; añade una entrada si introduces uno nuevo. - Todo componente visual recibe también los
COMMON_EVENTS(mouse_enter,mouse_leave,right_click) automáticamente (Component.all_events); el runtime envuelve el control en unmouse_areacuando están conectados. No los listes tú.
Traducciones y recarga en caliente
Sección titulada «Traducciones y recarga en caliente»- Los textos del propio diseñador se traducen en
python/pyzed/designer/locales/es.json. Añade la etiqueta en español y cualquier categoría o título de propiedad nuevo bajo"designer"(la clave es el texto en inglés):"Rating": "Valoración". Los títulos de propiedad se comparten, así queValue,Max,SizeyTintya están. - Si el usuario cambia una propiedad al interactuar (como el
valuede Rating), añádela a_VALUE_PROPSenpython/pyzed/designer/runtime.py("rating": ("value",)): así la recarga en caliente conserva lo que eligió el usuario al guardar el formulario o su código.
3. Qué guarda y genera el editor
Sección titulada «3. Qué guarda y genera el editor»Colocar un Rating, ponerle nombre y hacer doble clic en él deja esto en el .pzform (solo type, la
geometría, props y events dependen de tu componente):
{ "name": "rating1", "type": "rating", "x": 16, "y": 48, "width": 132, "height": 28, "props": { "value": 3, "max": 5, "size": 16, "tint": "accent" }, "events": { "change": "rating1_change" }, "anchor": ["left", "top"], "visible": true, "enabled": true, "locked": false}y este stub en el módulo de handlers del formulario (Document.wire → code.ensure_function, con el
docstring de EVENT_DOCS):
def rating1_change(form, event): """Runs when the value of rating1 changes (``event.value``).""" passEn ejecución, los handlers leen y escriben las propiedades como atributos: form.rating1.value,
form.rating1.max = 10, form.rating1.hide(). Como las props se guardan como JSON, usa valores
compatibles con JSON (textos, números, booleanos, None), y cuando añadas una propiedad más adelante dale un
valor por defecto que no cambie el aspecto de los formularios antiguos.
4. Tests
Sección titulada «4. Tests»Los tests viven en tests/ y se ejecutan sin pantalla. test_every_component_has_defaults_and_renders, en
tests/test_designer.py, ya dibuja cada componente registrado con sus valores por defecto, así que un
render roto falla enseguida. Añade tests de lo propio de tu control:
import pyzed as pzfrom pyzed import designer, uifrom pyzed.designer.document import Documentfrom pyzed.designer.runtime import FormApp
def test_rating_ui_component(): assert isinstance(ui.rating(3, lambda stars: None, max=5), pz.Widget) assert isinstance(ui.rating(0), pz.Widget) # read only
def test_rating_in_the_toolbox(): spec = designer.component("rating") assert spec in designer.categories()["Common"] assert spec.default_event == "change" control = spec.create("stars", 0, 0) assert control.props == {"value": 3, "max": 5, "size": 16, "tint": "accent"} assert isinstance(designer.render(control, designer.RenderContext(pz.themes.ONE_DARK)), pz.Widget)
def test_rating_runs_its_handler(tmp_path): doc = Document(designer.Form(name="review")) doc.add("rating", 16, 16) handler = doc.wire("rating1", "change") doc.set_handler_source(handler, "def rating1_change(form, event):\n form.rating1.max = 10\n") path = doc.save(tmp_path / "review.pzform") assert designer.load(path)["rating1"].events == {"change": "rating1_change"} rt = designer.open_form(path).runtime rt.ctx.on(rt.form["rating1"], "change", "value")(4) # what a click on the 4th star does assert rt.form["rating1"].props["value"] == 4 assert rt.form["rating1"].props["max"] == 10 assert rt.error is None
def test_rating_screenshot(tmp_path): form = designer.Form(width=200, height=60) form.add(designer.component("rating").create("stars", 16, 16)) FormApp(form).screenshot(tmp_path / "rating.png", scale=1) assert (tmp_path / "rating.png").stat().st_size > 0rt.ctx.on(control, evento, prop)(valor)simula al usuario: es exactamente el callback que llama el widget.Documentmaneja el editor sin ventana:add,set_prop,wire,save,undo…FormApp(...).screenshot(ruta, scale=1)ydesigner.Designer(ruta).screenshot(...)dibujan sin pantalla el formulario en ejecución y el editor completo.- El fixture de
conftest.pymantiene las preferencias del editor fuera de tu carpeta de configuración real.
Ejecuta pytest tests/test_designer.py -q mientras trabajas y todos los tests antes de hacer push.
Ejemplo completo: un control Rating
Sección titulada «Ejemplo completo: un control Rating»Este es el ejemplo completo, en un solo archivo que puedes ejecutar sin tocar el repositorio: crea la función
de ui, registra la entrada de la caja de herramientas, crea un formulario con Document, conecta el evento,
ejecuta el formulario y hace capturas. (En un pull request, cada pieza va al archivo indicado en los pasos de
arriba).
"""A Rating control for Pyzed Design."""
from typing import Any, Optional
from pyzed import _corefrom pyzed.designer import Component, Prop, registerfrom pyzed.designer.components import TINTS, RenderContextfrom pyzed.designer.model import Controlfrom pyzed.icons import icon as _iconfrom pyzed.theme import Theme, alphafrom pyzed.ui.base import _theme
def rating( value: float, on_change: Any = None, *, max: int = 5, # noqa: A002 - mirrors ui.progress size: float = 16, tint: str = "accent", theme: Optional[Theme] = None,) -> _core.Widget: """A row of ``max`` stars with the first ``value`` lit, in the ``tint`` color.
``on_change`` is called with the number of the clicked star (1 to ``max``); without it the stars are read only. """ t = _theme(theme) stars = [] for i in range(1, int(max) + 1): lit = i <= value star = _icon("star", size=size, color=t.color(tint) if lit else alpha(t.text_muted, 0.5)) stars.append(_core.button(star, (lambda i=i: on_change(i)) if on_change else None, padding=2, bg="transparent", hover_bg=t.ghost_hover, radius=t.radius_sm)) return _core.row(stars, spacing=0, align="center")
def _rating(c: Control, ctx: RenderContext) -> Any: p = c.props widget = rating(p["value"], ctx.on(c, "change", "value") if c.enabled else None, max=int(p["max"]), size=p["size"], tint=p["tint"], theme=ctx.theme) return _core.container(widget, width="fill", height="fill", align_y="center")
RATING = register(Component( "rating", "Rating", "star", "Common", _rating, (132, 28), (Prop("value", "number", 3, min=0, max=10, group="Data"), Prop("max", "number", 5, min=1, max=10, group="Data"), Prop("size", "number", 16, min=8, max=48), Prop("tint", "choice", "accent", choices=TINTS)), ("change",), "A row of stars to rate something; change gives the number of stars.",))from pathlib import Path
import rating # noqa: F401 - registra el componente
from pyzed import designerfrom pyzed.designer import Document
HERE = Path(__file__).resolve().parent
doc = Document(designer.Form(name="review", title="Review", width=360, height=150))doc.add("label", 16, 16, 320, 24).props["text"] = "How was your stay?"doc.add("rating", 16, 48)doc.add("label", 16, 88, 320, 24)doc.rename("label2", "status")handler = doc.wire("rating1", "change") # "rating1_change", con un stub en review.pydoc.set_handler_source(handler, ( "def rating1_change(form, event):\n" ' form.status.text = f"You gave it {event.value} stars"\n'))path = doc.save(HERE / "review.pzform") # escribe review.pzform y review.py
app = designer.open_form(path)rt = app.runtimert.ctx.on(rt.form["rating1"], "change", "value")(4) # un clic en la cuarta estrellaassert rt.form["status"].props["text"] == "You gave it 4 stars"app.screenshot(HERE / "rating-run.png", scale=2) # la imagen del principio de esta página
editor = designer.Designer(path)editor.doc.select("rating1")editor.screenshot(HERE / "rating-designer.png", size=(1280, 760), scale=2)python demo.py escribe el formulario, sus handlers y las dos capturas. Para usar el control de forma
interactiva, importa rating antes de abrir el editor o ejecutar el formulario:
import rating # noqa: F401from pyzed import designer
designer.design("review.pzform") # el editor, con Rating en la caja de herramientas# designer.run("review.pzform") # o ejecuta el formularioEl Rating seleccionado en el lienzo, con sus propiedades Data y Appearance en el panel:

Lista de comprobación
Sección titulada «Lista de comprobación»- Función de
pyzed.uicon docstring, exportada enui/__init__.pyy en__all__ - Cambios de Rust (si los hay) formateados y sin avisos de clippy, recompilados con
maturin develop --release - Entrada
Componentendesigner/components.py, con untypeestable, un icono de Lucide y una descripción de una frase - Callbacks con
ctx.on(c, evento, prop); valores convertidos con_num/_lines - Textos en español en
designer/locales/es.json; propiedades interactivas en_VALUE_PROPS - Tests en
tests/, ypytest testspasa - Documentación: mira Documentar un componente
- PR con el título
✨ Add a Rating component