Una aplicación, varias pantallas
Traducción automática
Esta página se tradujo automáticamente del inglés y todavía no la revisó un hablante nativo; las correcciones son bienvenidas en GitHub. Si algo no coincide, vale el original en inglés.
Una página escrita con los elementos del UI Engine — ui:window, ui:panel, ui:table, ui:form… — es a la vez una página web, una aplicación de terminal y una ventana de escritorio. Hay un solo entorno de ejecución: las consultas, las acciones y las reglas de la página se ejecutan una vez, en el servidor, y cada renderizador solo dibuja el resultado.
| Ábrela con | Obtienes |
|---|---|
quantum start | la página en un navegador |
quantum console | la misma página en la terminal (Textual) |
quantum desktop | la misma página en una ventana nativa (pywebview) |
Nada de la lógica de la página se traduce a otro lenguaje, así que una regla que escribes una vez — una validación, un inicio de sesión, una consulta — se comporta igual en todas las pantallas.
Una primera pantalla
Guárdala como components/index.q en un proyecto (una carpeta con un quantum.config.yaml) y ejecuta quantum start:
<q:component name="Counter">
<q:action name="add" method="POST">
<q:set name="current" value="{session.clicks}" default="0" type="number" />
<q:set name="session.clicks" value="{current + 1}" />
<q:redirect url="/" />
</q:action>
<q:set name="clicks" value="{session.clicks}" default="0" />
<ui:window title="Counter">
<ui:panel title="Clicks">
<ui:text>You clicked {clicks} times.</ui:text>
<ui:button on-click="add" variant="primary">Add</ui:button>
</ui:panel>
</ui:window>
</q:component>Shows: Clicks · You clicked 0 times. · Add
Ahora ejecuta quantum console en la misma carpeta: el mismo panel, el mismo texto y el mismo botón, en la terminal. Presionar Add ahí envía el mismo q:action, con una sesión propia, igual que un navegador.
quantum desktop la abre en una ventana. Necesita un paquete adicional:
pip install "quantum-framework[desktop]"
quantum desktop # the home page
quantum desktop /reports # another page, --width/--height to size the windowEl título de la ventana es el title del primer ui:window — el mismo título que muestran la pestaña del navegador y la consola.
Un parámetro
La aplicación entera, en la versión que pidas — pásale la carpeta de la aplicación:
quantum run projects/kanban --on web # the browser (quantum start)
quantum run projects/kanban --on desktop # a window of its own (quantum desktop)
quantum run projects/kanban --on terminal # the terminal (quantum console)--page /board/1 abre en otra página, --port fija el puerto de la web. --on tui, una pantalla de texto dibujada por Godot, está en camino (PLAN_PLATFORMS.md) y lo dice hasta entonces. El Kanban del repositorio ejecuta un mismo guion — entrar, mover tarjetas, las subtareas de la IA, importar issues, un lector que no cambia nada — en todas las versiones, en el CI (tests/apps/test_kanban_everywhere.py).
Los eventos son acciones
Un botón o un formulario no llama código en el navegador: envía datos a un q:action de la página. La acción valida, hace su trabajo y redirige, como cualquier formulario en Quantum (ver Acciones y formularios).
<ui:button on-click="save">envía a<q:action name="save">.with="id={t.id}, filter={filter}"agrega campos a ese envío — así un botón en una fila dice qué fila es.<ui:form on-submit="create">envía sus campos:<ui:input bind="title">es el campotitle.
Un evento que no nombra ninguna acción de la página es un error que lista las acciones de la página — nunca un botón que no hace nada en silencio.
Tablas y listas a partir de datos
source= recibe una lista — un q:query o un array — y dibuja una fila por elemento. La variable de la fila se nombra con as= (por defecto row para una tabla, item para una lista), exactamente como un q:loop:
<q:component name="People">
<q:action name="delete" method="POST">
<q:param name="id" type="integer" required="true" />
<q:redirect url="/" flash="Deleted person {id}" />
</q:action>
<q:set name="people" type="array"
value='[{"id": 1, "name": "Ana", "age": 30}, {"id": 2, "name": "Bia", "age": 25}]' />
<ui:window title="People">
<ui:table source="{people}" as="p">
<ui:column key="name" label="Name" />
<ui:column key="age" label="Age" align="right" />
<ui:column label="">
<ui:button on-click="delete" with="id={p.id}" variant="danger">Delete {p.name}</ui:button>
</ui:column>
</ui:table>
<ui:list source="{people}" as="p">
<ui:item><ui:text>{p.name} is {p.age} years old</ui:text></ui:item>
</ui:list>
</ui:window>
</q:component>Shows: Name · Age · Ana · 30 · Delete Bia · Bia is 25 years old
<ui:column key="name">muestra ese campo de la fila, escapado.- Una columna con contenido lo dibuja una vez por fila — botones, enlaces, insignias.
- Un
sourceque no es una lista, o unakeyque la fila no tiene, es un error que lo dice (con los campos de la fila) — nunca una tabla vacía.
Con una base de datos, el origen es una consulta. Los ejemplos de aquí en adelante usan esta base de datos (CI la construye a partir de este bloque):
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
done INTEGER NOT NULL DEFAULT 0
);
INSERT INTO tasks (title, priority) VALUES ('Write the guide', 'high'), ('Review it', 'low');
CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 12)
INSERT INTO posts (title) SELECT 'Post ' || i FROM n;<q:component name="Tasks">
<q:query name="tasks" datasource="db">
SELECT id, title FROM tasks ORDER BY id
</q:query>
<ui:window title="Tasks">
<ui:table source="{tasks}">
<ui:column key="title" label="Task" />
</ui:table>
</ui:window>
</q:component>Shows: Task · Write the guide · Review it
Páginas de una lista larga
Una consulta con paginate="true" devuelve una página; la página es el ?page= de la URL. <ui:pager> dibuja los enlaces:
<q:component name="Blog">
<q:query name="posts" datasource="db" paginate="true" page_size="10">
SELECT title FROM posts ORDER BY id DESC
</q:query>
<ui:window title="Blog">
<ui:list source="{posts}" as="p"><ui:item><ui:text>{p.title}</ui:text></ui:item></ui:list>
<ui:pager for="posts" />
</ui:window>
</q:component>Shows: Post 12 · Post 3
Las 12 publicaciones forman dos páginas: la primera muestra de Post 12 a Post 3.
- Anterior, los números alrededor de la página actual (el primero y el último siempre,
…donde se salta), siguiente. En los extremos, anterior/siguiente no son enlaces; con una sola página no se dibuja nada. - Los enlaces conservan los demás parámetros de la URL: en
/?tag=news&page=2van a/?tag=news&page=3. ?page=abco?page=-1es la página 1, nunca un error.window="1"muestra menos números;param="p"(conpage="{query.p}"en la consulta) cuando una página tiene dos listas paginadas.
projects/blog pagina su página de inicio de esta forma.
Buscar mientras escribes
<q:component name="Search">
<q:set name="term" value="{query.q}" default="" />
<q:query name="found" datasource="db">
SELECT title FROM posts WHERE title LIKE :p
<q:param name="p" value="%{term}%" type="string" />
</q:query>
<ui:window title="Search">
<ui:input bind="q" search="results" placeholder="Search" />
<ui:vbox id="results">
<ui:list source="{found}" as="a"><ui:item><ui:text>{a.title}</ui:text></ui:item></ui:list>
</ui:vbox>
</ui:window>
</q:component>Shows: Post 1 · Post 12
- Cada pausa al escribir (
delay, 300 ms por defecto) pide la misma página con?q=…y reemplaza solo#results— la búsqueda la hacen las propias consultas de la página. La URL lo acompaña, así que el resultado se puede compartir o recargar. - Por debajo es un formulario GET común: sin JavaScript, Enter busca.
- Los demás parámetros de la URL se conservan;
pagese descarta, así que una búsqueda nueva empieza en la página 1 de un<ui:pager>. - En la consola, lo mismo: después de una pausa se vuelve a pedir la página, y el campo conserva el foco.
- Un destino que no está en la página es un error — nunca un campo que no reemplaza nada.
projects/blog busca de esta forma (components/search.q).
Una tabla que se ordena y se edita sola
<q:component name="Sheet">
<q:query name="tasks" datasource="db" sortable="true">
SELECT id, title, priority FROM tasks
</q:query>
<ui:window title="Tasks">
<ui:table source="{tasks}" sort="true" edit="tasks" datasource="db">
<ui:column key="title" label="Title" />
<ui:column key="priority" label="Priority" />
</ui:table>
</ui:window>
</q:component>Shows: Title · Priority · low · medium · high
Cada celda es un pequeño formulario: los títulos están en sus campos, y cada prioridad es un select con la lista del CHECK.
sort="true": cada encabezado es un enlace que ordena la consulta en SQL (sortable="true"en la consulta) según?sort=y?dir=— así que funciona con un<ui:pager>. Una columna en la URL que la consulta no devuelve se ignora.edit="tasks": cada columna mostrada de esa tabla se convierte en un pequeño formulario en su celda (Enter o ✓ guarda). No escribes ninguna acción: el servidor acepta solo la tabla y las columnas que declara esta página, ejecuta las guardas de la página, valida el valor con las reglas de la columna tomadas del esquema (NOT NULL,CHECK … IN, tipo) y actualiza una fila por su clave primaria. Un valor rechazado vuelve a su celda con el error.edit="false"en una columna la deja de solo lectura. Cada celda va firmada para la sesión a la que se mostró: una tabla dentro de unq:iffalso para esa persona, o una fila que su consulta dejó fuera, no se puede editar con un formulario hecho a mano.- Las filas deben incluir la clave primaria (
SELECT id, …). - Lo que hace se muestra en
/_devcomo cualquier acción.
projects/tasks tiene una en /sheet.
Formularios que se abren con valores
Los campos toman su valor inicial de la página, así que el mismo formulario crea y edita:
<q:component name="Profile">
<q:action name="save" method="POST">
<q:param name="name" required="true" minlength="2" />
<q:param name="notices" default="off" />
<q:param name="plan" default="free" />
<q:set name="session.name" value="{name}" />
<q:redirect url="/" flash="Saved: {name}, notices {notices}, plan {plan}" />
</q:action>
<q:set name="name" value="{session.name}" default="Ana" />
<ui:window title="Profile">
<q:if condition="flash">
<ui:alert variant="success">{flash}</ui:alert>
</q:if>
<ui:form on-submit="save">
<ui:formitem label="Name">
<ui:input bind="name" value="{name}" />
</ui:formitem>
<ui:checkbox bind="notices" label="Receive notices" checked="true" />
<ui:radio bind="plan" options="free,pro" value="pro" />
<ui:select bind="color" options="blue,green" value="green" />
<ui:button variant="primary">Save</ui:button>
</ui:form>
</ui:window>
</q:component>Shows: Name · Receive notices · free · pro · Save
default= le da su valor a la primera visita (la sesión todavía no tiene name).
value=enui:input,ui:selectyui:radio;checked=enui:checkboxyui:switch(true, o una expresión).- Una casilla marcada se envía como
on; una sin marcar no se envía — así lo hacen los navegadores. Por eso la acción dice<q:param name="notices" default="off">.
Formularios que conocen las reglas de la acción
Un formulario toma las reglas de cada campo de los q:param de la acción a la que envía, así que las escribes una sola vez:
<q:component name="SignUp">
<q:action name="signUp" method="POST">
<q:param name="name" required="true" minlength="3" />
<q:param name="age" type="integer" min="18" />
<q:param name="plan" enum="free,pro" default="free" />
<q:redirect url="/" flash="Signed up: {name}" />
</q:action>
<ui:window title="Sign up">
<ui:form on-submit="signUp">
<ui:formitem label="Name"><ui:input bind="name" /></ui:formitem>
<ui:formitem label="Age"><ui:input bind="age" /></ui:formitem>
<ui:select bind="plan" />
<ui:button>Sign up</ui:button>
</ui:form>
</ui:window>
</q:component>Shows: Name · Age · free · pro · Sign up
namereciberequired minlength="3",agerecibetype="number" min="18", y el select recibe elenumcomo sus opciones — mira el código fuente de la página. El navegador los verifica antes de enviar.- El servidor igual valida cada campo. Cuando rechaza, la página vuelve con los valores que se enviaron y cada error junto a su campo (también en la consola). Una contraseña nunca se devuelve.
- Un atributo que escribes en un campo tiene prioridad;
<ui:form rules="off">desactiva esto. - Un
patternpasa a ser del navegador solo cuando está anclado (^…$): el navegador compara el valor completo, el servidor busca.
Formularios a partir de una tabla
Cuando una acción escribe en una tabla, puede tomar sus reglas de la propia tabla — el esquema que ya escribiste en la migración (la tabla tasks de arriba):
<q:component name="EditTask">
<q:action name="save" method="POST" table="tasks" datasource="db" columns="title,priority">
<q:param name="id" type="integer" required="true" />
<q:query name="updated" datasource="db">
UPDATE tasks SET title = :title, priority = :priority WHERE id = :id
<q:param name="title" value="{title}" type="string" />
<q:param name="priority" value="{priority}" type="string" />
<q:param name="id" value="{id}" type="integer" />
</q:query>
<q:redirect url="/" flash="Saved: {title}" />
</q:action>
<q:query name="task" datasource="db">SELECT id, title, priority FROM tasks WHERE id = 1</q:query>
<ui:window title="Edit task">
<ui:form on-submit="save" values="{task}" submit="Save" />
</ui:window>
</q:component>Shows: Title · Priority · low · high · Save
titleesNOT NULL→ obligatorio;prioritytieneCHECK … IN→ un select con esas opciones, y el servidor rechaza cualquier otra cosa.- El formulario no tiene campos propios, así que dibuja uno por columna — etiquetas tomadas de los nombres (
author_id→ "Author"), una casilla paraBOOLEAN, un select para una clave foránea completado con las filas de la tabla referenciada.values="{task}"lo abre con los valores de una fila: un formulario de edición en una línea. columns=elige y ordena las columnas (por defecto: todas menos la clave primaria). Unq:paramque escribes en la acción tiene prioridad sobre el de la columna.- Una columna que acepta nulos y se deja en blanco llega a la acción como
None, y una clave foránea debe nombrar una fila que existe. - El esquema se lee de la base de datos, y se vuelve a leer cuando cambia el archivo de la base de datos — agrega una columna en una migración y el formulario la tiene.
quantum checkinforma una tabla o columna que no existe.
projects/tasks edita una tarea de esta forma (components/task/[id].q).
Diseño que se adapta
El diseño se declara, con tres puntos de quiebre: sm (640 px), md (768 px) y lg (1024 px). En la consola se cuentan en columnas (80, 96 y 128).
<q:component name="Dashboard">
<ui:window title="Dashboard">
<ui:hbox stack-below="md" gap="md">
<ui:vbox width="260"><ui:text>Menu</ui:text></ui:vbox>
<ui:vbox grow="true"><ui:text>Content</ui:text></ui:vbox>
</ui:hbox>
<ui:grid columns="1 sm:2 lg:3">
<ui:text>One</ui:text><ui:text>Two</ui:text><ui:text>Three</ui:text>
</ui:grid>
<ui:text hide-below="md">Only on wide screens</ui:text>
</ui:window>
</q:component>Shows: Menu · Content · One · Three
stack-below="md"pone los hijos de unui:hboxuno debajo del otro por debajo de 768 px, y sus anchos fijos dejan de aplicarse.grow="true"ocupa el resto de la fila.ui:grid columns="1 sm:2 lg:3": una columna, dos desdesm, tres desdelg.hide-below/hide-aboveocultan un elemento a un lado de un punto de quiebre.
Movimiento
Tres tipos de movimiento — fade, slide, expand — a dos velocidades — fast (150 ms) y normal (250 ms, la predeterminada) — iguales en el navegador, el escritorio y la terminal:
<q:component name="Board">
<q:set name="moved" value="{query.moved}" default="2" type="number" />
<q:set name="cards" type="array" value='[{"id": 1, "title": "Fonts"}, {"id": 2, "title": "Domain"}]' />
<ui:window title="Board" transition="slide">
<q:loop items="{cards}" var="card">
<ui:card title="{card.title}" enter="{card.id == moved and 'slide' or ''}" />
</q:loop>
<ui:animate type="expand" speed="fast">
<ui:text>Opens quickly</ui:text>
</ui:animate>
</ui:window>
</q:component>Shows: Fonts · Domain · Opens quickly
transitionenui:window(fadeoslide): la página entra así cada vez que se muestra.enteren cualquier elemento, también una expresión: el elemento entra así cuando se muestra su página;''es ningún movimiento.<ui:animate type="fade|slide|expand">hace lo mismo con lo que envuelve.- El contenido de una pestaña se abre con
expandcuando el usuario cambia de pestaña. speed="fast"en cualquiera de ellos.
ui.motion: off en quantum.config.yaml apaga todo movimiento; el navegador también los detiene cuando el sistema pide movimiento reducido. Cada renderizador registra los movimientos que hizo (window.quantumMotions en el navegador), para que una prueba compruebe que todas las versiones se movieron igual. Los otros tipos de ui:animate (bounce, scale…) son solo del navegador; en los demás, el contenido aparece sin ellos.
El conjunto del Núcleo
Estos elementos los dibujan con el mismo significado el navegador, la consola y la ventana de escritorio, y un mismo script de prueba — ver, completar, marcar, elegir, hacer clic — se ejecuta sin cambios en un navegador real y en la consola:
| Tipo | Elementos |
|---|---|
| Diseño | window, hbox, vbox, grid, panel, section, scrollbox, spacer, rule, header, footer, card (card-header, card-body, card-footer), tabpanel / tab |
| Contenido | text, badge, alert, link, image, progress |
| Datos | table / column, list / item |
| Formularios | form, formitem, input, checkbox, switch, radio, select / option, button |
| Funciones de datos | pager (páginas de una consulta), history (los cambios de una fila), stream (una respuesta de IA a medida que se escribe) |
Cualquier otro elemento ui:* — gráficos, modales, toasts, selectores de fecha, menús… — funciona solo en el navegador y es Experimental (ver la referencia de las etiquetas UI). En la consola, un elemento así muestra [ui:chart is not drawn in the console] en lugar de otra cosa en su lugar.
El texto directamente dentro de un contenedor es contenido: <ui:card-header>Summary</ui:card-header>. Puede haber HTML común entre los elementos ui:*; la consola muestra su texto.
Builds independientes
<q:application type="ui"> con quantum run app.q --target html (un único archivo HTML) o --target textual (un único archivo Python) dibuja solo el diseño: no hay entorno de ejecución en esos archivos, así que un q:set, un q:function o cualquier otro comando en ellos es un error que apunta aquí. Escribe la pantalla como una página para darle lógica.
--target mobile (React Native) es Laboratorio: traduce la lógica a JavaScript por su cuenta, sin promesa de estabilidad. Los teléfonos no forman parte de la 1.0. El antiguo --target desktop se eliminó; quantum desktop lo reemplaza.